I Built an ethers.js-Style SDK for Hyperledger Fabric in Go
Muhammad Talha4 min read·Just now--
The boilerplate problem that every Fabric developer knows too well
If you’ve ever built on Ethereum, you know how clean the developer experience feels:
const token = new ethers.Contract(address, abi, signer)
const balance = await token.balanceOf(addr) // read
const tx = await token.transfer(to, amount) // write
await tx.wait()Three lines. The library handles the RPC connection, wallet signing, and confirmation waiting. You focus on your application logic.
Now try doing the same thing with Hyperledger Fabric in Go.
What you actually write today
Before you can call a single chaincode function, the official Fabric Gateway SDK requires you to wire up TLS certificates, gRPC connections, identity loading, and gateway configuration manually — every time, in every service:
certPEM, _ := os.ReadFile(config.TLSCertPath)
cert, _ := identity.CertificateFromPEM(certPEM)
pool := x509.NewCertPool()
pool.AddCert(cert)conn, _ := grpc.Dial(config.PeerEndpoint,
grpc.WithTransportCredentials(
credentials.NewClientTLSFromCert(pool, config.PeerHostOverride),
),
)id, _ := loadIdentityFromDir(config.CertPath, config.MSPID)
sign, _ := loadSignerFromDir(config.KeyPath)gw, _ := client.Connect(id,
client.WithSign(sign),
client.WithHash(hash.SHA256),
client.WithClientConnection(conn),
client.WithEvaluateTimeout(5*time.Second),
client.WithEndorseTimeout(15*time.Second),
client.WithSubmitTimeout(5*time.Second),
client.WithCommitStatusTimeout(1*time.Minute),
)network := gw.GetNetwork(config.ChannelName)
contract := network.GetContractWithName(config.ChaincodeName, config.ContractName)
And that’s just the connection. Every chaincode function is another 15–20 lines on top of that — marshalling, SubmitAsync, commit.Status(), and manually digging through gRPC status details to find the actual chaincode error message buried inside a proto.
On a real project with multiple chaincodes and multiple services, this becomes hundreds of lines of pure infrastructure duplication.
Introducing fabricsdk
I spent time abstracting all of this into a single reusable Go package:
go get github.com/muhammadtalha198/fabricsdkHere is the exact same setup, rewritten:
cfg := fabricsdk.Config{
PeerEndpoint: "localhost:7051",
PeerHostOverride: "peer0.org1.example.com",
TLSCertPath: "/path/to/peer-tls-ca.pem",
CertPath: "/path/to/msp/signcerts",
KeyPath: "/path/to/msp/keystore",
MSPID: "Org1MSP",
ChannelName: "mychannel",
ChaincodeName: "mychaincode",
}fc, err := fabricsdk.New(cfg)
if err != nil { log.Fatal(err) }
defer fc.Close()ctx := context.Background()// Read — no ledger write
raw, err := fc.Evaluate(ctx, "GetAsset", "asset-1")// Write — endorse, submit, wait for commit, return txID
txID, err := fc.Submit(ctx, "CreateAsset", string(jsonBytes))
That is the entire setup. No manual gRPC wiring. No TLS pool construction. No proto digging.
How it maps to ethers.js
The concepts translate directly. JsonRpcProvider becomes your Config.PeerEndpoint. Your Wallet private key becomes Config.CertPath and Config.KeyPath — the x509 identity Fabric uses instead of a raw private key. new Contract(address, abi, signer) becomes fabricsdk.New(cfg).
Reading with token.balanceOf(addr) becomes fc.Evaluate(ctx, "balanceOf", addr). Writing with token.transfer(to, amt) becomes fc.Submit(ctx, "transfer", to, amt). The transaction hash is the txID string returned by Submit. And await tx.wait() is already built into Submit — it blocks until the block is committed before returning.
One thing that is actually simpler than ethers.js: no ABI file needed. In Ethereum you need a JSON ABI to describe what functions exist on a contract. In Fabric, chaincode functions are called by name string directly. fc.Submit("AnyFunctionName", arg1, arg2) works for any chaincode deployed now or in the future, with zero SDK changes.
What else the SDK handles
Per-call identity override
Fabric is permission-based — different org members sign different transactions. The SDK supports this without opening a new TCP connection:
// Default identity (from Config)
txID, err := fc.Submit(ctx, "CreateAsset", payload)// Admin identity for a single call — same gRPC connection, different signer
txID, err = fc.WithIdentity("/admin/signcerts", "/admin/keystore").
Submit(ctx, "DeleteAsset", "asset-1")This is equivalent to token.connect(adminSigner) in ethers.js.
Structured errors — no string parsing
The official SDK buries the real chaincode error message inside nested gRPC status details. The SDK extracts it and returns a typed error with an HTTP-style status code:
txID, err := fc.Submit(ctx, "CreateAsset", payload)if fabricsdk.IsNotFound(err) { /* 404 */ }
if fabricsdk.IsConflict(err) { /* 409 — optimistic lock, re-read and retry */ }
if fabricsdk.IsUnauthorized(err) { /* 403 */ }var fabErr *fabricsdk.Error
if errors.As(err, &fabErr) {
http.Error(w, fabErr.Message, fabErr.Code) // forward directly to API handler
}
Chaincode event streaming
ctx, cancel := context.WithCancel(context.Background())
defer cancel()events, err := fc.Events(ctx)
for ev := range events {
fmt.Printf("event=%s txID=%s payload=%s\n",
ev.EventName, ev.TxID, ev.Payload)
}// Replay from a known block after a restart
events, err = fc.EventsFrom(ctx, lastKnownBlock)
Dynamic multi-chaincode calls
When you need to call multiple chaincodes from one connection without reconfiguring:
dyn := fc.Dynamic()raw, err := dyn.Evaluate(ctx, "channelA", "ccA", "ContractA", "GetAsset", "id-1")
txID, err := dyn.Submit(ctx, "channelB", "ccB", "ContractB", "CreateAsset", payload)Pluggable logger
type zapAdapter struct{ l *zap.SugaredLogger }
func (a *zapAdapter) Info(msg string, kv ...any) { a.l.Infow(msg, kv...) }
func (a *zapAdapter) Error(msg string, kv ...any) { a.l.Errorw(msg, kv...) }cfg.Logger = &zapAdapter{l: logger.Sugar()}The recommended pattern: thin typed wrappers
For production applications, build a small domain-specific wrapper on top of the generic SDK. This is the same typed-wrappers pattern you’d use with ethers.js and TypeScript:
// internal/asset/client.go
package assettype Client struct{ fc *fabricsdk.FabricClient }func NewClient(fc *fabricsdk.FabricClient) *Client { return &Client{fc} }func (c *Client) Create(req CreateRequest) (string, error) {
b, _ := json.Marshal(req)
return c.fc.Submit(context.Background(), "CreateAsset", string(b))
}func (c *Client) Get(id string) (*Asset, error) {
raw, err := c.fc.Evaluate(context.Background(), "GetAsset", id)
if err != nil { return nil, err }
var a Asset
json.Unmarshal(raw, &a)
return &a, nil
}
A new chaincode becomes one small wrapper file. The connection, identity, and error handling code never gets touched again.
The numbers
Before the SDK, connecting and calling one chaincode function required roughly 60 lines for setup and 20 lines per function. With fabricsdk, setup is 10 lines and each function is 3–4 lines. Error classification that previously needed manual gRPC proto inspection is now a single predicate call. Identity overrides that previously required rebuilding the entire gateway are now one chained method.
Get it
go get github.com/muhammadtalha198/fabricsdkFull documentation: https://pkg.go.dev/github.com/muhammadtalha198/fabricsdk
GitHub: https://github.com/muhammadtalha198/fabricsdk
MIT licensed. Requires Go 1.21+.
Issues, feedback, and PRs are welcome. If you’re building on Hyperledger Fabric and have spent time copying the same gRPC boilerplate between services — this was built for exactly that problem.