Terra SDKbuilding the same library three times

I built Terra's blockchain SDK three times: first in Python, then TypeScript, then Java.
Each SDK needed types for every message the chain accepted, serializers for its wire formats, key management, transaction signing, and clients for the node APIs. The work covered hundreds of small protocol types with no natural build order. I needed to define what the library promised, choose a first piece, and create units of work that could actually be finished.
The third implementation added another problem. Three hand-written SDKs could disagree about the same protocol, so I also needed to decide how their definitions would be shared across languages.
The SDK should model Terra's protocol
A Terra transaction can be sent without an SDK. The node accepts JSON over HTTP, so an application can build an object, sign it, and send it with a normal HTTP client.
An SDK that only wraps those HTTP calls saves little. I wanted the core package to give programmers Terra's protocol in their own language. Every accepted message should have a named type with the fields the chain reads. Addresses, coins, fees, and signatures remain distinct protocol types throughout the application.
The simplest example is a token transfer:
export class MsgSend extends JSONSerializable<
MsgSend.Amino,
MsgSend.Data,
MsgSend.Proto
> {
public amount: Coins;
constructor(
public from_address: AccAddress,
public to_address: AccAddress,
amount: Coins.Input
) {
super();
this.amount = new Coins(amount);
}The signature tells the caller that the sender and recipient are account addresses and that the amount is a collection of coins. The base class carries the three serialization formats Terra used. The caller constructs a protocol message directly; the class owns the raw JSON shape.
That promise determined the package layout:
| Package | Responsibility |
|---|---|
core | protocol types and messages |
client | node communication |
key | keys and transaction signing |
util | shared serialization and helpers |
extension | the browser-wallet surface in TypeScript |
The core package cannot depend on a node client. A program should be able to construct, inspect, serialize, and test messages without choosing a URL or opening a connection. The imports for MsgSend show that boundary:
import { Coins } from '../../Coins';
import { JSONSerializable } from '../../../util/json';
import { AccAddress } from '../../bech32';
import { MsgSend as MsgSend_pb } from '@terra-money/terra.proto/...';The message depends on other protocol values, a serialization helper, an address type, and generated protobuf code. Network state stays in client; secrets and signing stay in key.
I started with serialization
The Python SDK began with serializer and deserializer classes. Serialization was small enough to implement immediately and sat underneath every message type I would add later.
It also forced the wire format to become concrete. The node expects particular field names, number encodings, and nesting for a coin. A mistake in the serializer propagates into every message built on top of it.
Starting there meant some early code would be rewritten as the architecture developed. I preferred that rewrite to designing the whole package around an imagined wire model and discovering the mistake after hundreds of message classes depended on it.
This gave me a practical rule for starting large libraries. The first unit should expose an assumption shared by the rest of the system and be small enough to finish. Serialization met both conditions.

I designed the public API by writing applications against it
Once serialization worked, I needed to decide how the library should feel to an application developer.
The chain's module structure offered an easy template. I could mirror its directories and expose each endpoint and message where the underlying implementation placed it. That would produce an accurate library organized around how the chain stores and validates data.
Applications are organized around what a developer is trying to do. To design for that side of the boundary, I wrote small applications against the unfinished TypeScript SDK. Awkward call sites showed where the library exposed chain internals, required repeated conversions, or placed related operations in different packages.
The timing mattered. An example written after the API is stable demonstrates the library. An application written while the API can still change designs it. I expected those applications to break the SDK and treated each break as feedback on the interface.

This kept protocol fidelity and caller ergonomics separate. The core types still matched Terra exactly. The public methods and package entry points were shaped by how applications used those types.
Tests gave the work a finish line
The Java SDK exposed a planning problem I had already encountered in the first two implementations. A protocol surface with hundreds of types has no visible edge. I could work for several days and still be unable to point to a finished unit.
I considered two ways to traverse the surface:
- Finish one class completely, then move to the next.
- Sketch the basic shape of every core class, then fill in their behavior.
The first approach finds deep representation problems early. The second establishes coverage. Neither defines a durable completion signal by itself.
Tests supplied that signal. A passing test named one behavior that existed, stayed in the repository, and failed again if a later change broke it. I could count completed behaviors across partially implemented classes.
I wrote the tests as statements about protocol behavior:
describe('Coins', () => {
it('clobbers coins of similar denom', () => {
const coins1 = new Coins([
new Coin('ukrw', 1000),
new Coin('uluna', 1000),
new Coin('uluna', 1000),
]);
expect(coins1.get('uluna').amount.toNumber()).toEqual(2000);
});Two coin values with the same denomination collapse into one amount. Terra enforces that rule, so the SDK needs to enforce it too. The test marks a finished piece of work and records the behavior the type must preserve.

Tests became planning units and specifications. I could work in depth or breadth while measuring progress in executable behaviors.
Package boundaries follow what callers need independently
I split the TypeScript SDK into core, client, key, util, and extension so applications could install and audit the parts they used.
Key management and node communication have different dependencies and risk profiles. One handles secrets and signing; the other holds a URL, request code, and retry behavior. The browser extension existed only in the TypeScript implementation, so putting it in the shared core would create empty counterparts in Python and Java.
The directory structure made the separation visible:
core/ client/lcd/
Coin.ts Msg.ts LCDClient.ts
Coins.ts Fee.ts APIRequester.ts
Dec.ts Tx.ts Wallet.ts
Int.ts SignDoc.ts api/
Denom.ts PublicKey.ts
core contains values and messages. client contains connections and request policy. If core compiles and its tests pass with no client installed, the protocol vocabulary is genuinely independent of network access.

The same criterion works better than dividing packages according to the source repository. A package boundary should let a caller take less of the system or let one part be audited and changed without pulling in another.
Numeric types enforce chain precision
Terra balances are integers that can exceed JavaScript's exact numeric range. Decimal calculations also use a fixed precision. Returning native JavaScript numbers would silently round values before they reached a transaction.
I encoded the precision in the type:
export const DEC_PRECISION = 18;
export class Dec extends Decimal implements Numeric<Dec> {
public toString(): string {
return this.toFixed(DEC_PRECISION);
}Every Dec renders with the eighteen decimal places the chain expects. The type applies the formatting rule during serialization and keeps the value out of JavaScript's native numeric representation.

This is part of the SDK's protocol promise. Types should prevent invalid representations from reaching the wire. Documentation can explain the rule; the numeric class has to enforce it.
Finishing one numeric type exposed decisions that a broad API sketch would miss: parsing node responses, addition and division, conversions into application code, and serialization back to the chain. That justified doing depth-first work on the value types even while tests tracked progress across the larger surface.
Three languages need one source of truth
The Python, TypeScript, and Java SDKs described the same messages independently. A field added to one implementation could be missing from another while every local test suite still passed.
Code generation could remove the repeated definitions. I reduced the design to two possible sources:
A neutral protocol descriptor. A JSON or YAML schema defines messages, data objects, REST endpoints, and protobuf relationships. Each language generator reads the same description.
description
/ | \
TypeScript Rust Kotlin ...
\ | /
common
This gives every language equal status and makes the shared definition explicit. It also creates a schema that somebody must maintain alongside the chain. The descriptor can express only the constructs its schema anticipates, so unusual message types either expand the descriptor language or require exceptions.
A canonical SDK implementation. The TypeScript SDK becomes the definition and generators derive other languages from its types. This avoids inventing a second protocol language and begins from code that already exists. The generator must understand every TypeScript construct used by the canonical SDK, which turns the ongoing cost into maintaining a source-code translator.
The choice is therefore between maintaining a schema and maintaining a translator. A descriptor is cleaner when the protocol already has an authoritative machine-readable definition. A canonical implementation is cheaper when one SDK already contains the richest and most current definitions.

Framing the decision this way also identifies the owner. Protocol maintainers can own a neutral descriptor. SDK maintainers can own a canonical implementation and its translators. "Generate the other SDKs" is incomplete until that ownership is explicit.
The library's structure follows its guarantees
The same promise shaped every part of the three SDKs. Applications should construct Terra messages from named protocol types, without a network connection and without manually preserving wire-format rules.
That promise placed messages and values in core, kept clients and keys in separate packages, pushed precision into numeric types, and made behavioral tests the units of progress. Writing applications against the unfinished API kept the caller's workflow visible while those internal boundaries changed.
The third SDK exposed the limit of hand-written protocol definitions. Once several languages describe the same chain, the shared truth must live in a neutral descriptor or a canonical implementation. The maintainable choice depends on whether the project is prepared to own a schema or a translator.
An SDK looks like a collection of wrappers from the outside. Building one is the work of deciding which protocol guarantees belong in types, which dependencies callers should be able to avoid, and where the definitions live so every language keeps the same promise.