ADR-002: Module-Function Design
Status
Superseded — The pure module-function pattern described here evolved into a client-centric hub with method-bearing handles. See the current API where LoadedContract and DeployedContract have methods (.deploy(), .call(), .actions, .effect.*) rather than standalone functions.
The core principles (immutable data, Effect as source of truth, module namespaces) remain, but operations now live on handle objects rather than as module-level functions.
Context
Traditional SDKs often use class-based OOP patterns:
// Class-based approach (NOT what we do)class Client { constructor(config: ClientConfig) { ... } async contractFrom(options: ContractFromOptions): Promise<ContractBuilder> { ... }}
const client = new Client(config);const builder = await client.contractFrom({ module });We evaluated this approach against a module-function pattern where:
- Data structures are plain immutable interfaces
- Operations are standalone functions that take data as the first argument
- Related functions are grouped into module objects
Decision
We use the module-function pattern throughout the SDK:
Data as Plain Interfaces
// Plain data - NO methods, NO stateexport interface MidnightClient { readonly wallet: WalletContext | null; readonly networkConfig: NetworkConfig; readonly providers: ContractProviders; readonly logging: boolean;}
export interface ContractBuilder { readonly module: LoadedContractModule; readonly providers: ContractProviders; readonly logging: boolean;}
export interface ConnectedContract { readonly address: string; readonly instance: unknown; readonly module: LoadedContractModule; readonly providers: ContractProviders; readonly logging: boolean;}Operations as Module Functions
// Functions grouped by domainexport async function create(config: ClientConfig): Promise<MidnightClient> { ... }export async function contractFrom(client: MidnightClient, options: ContractFromOptions): Promise<ContractBuilder> { ... }
export const ContractBuilder = { deploy: async (builder: ContractBuilder, options?: DeployOptions): Promise<ConnectedContract> => { ... }, join: async (builder: ContractBuilder, options: JoinOptions): Promise<ConnectedContract> => { ... }, effect: { deploy: deployEffect, join: joinEffect },};
export const Contract = { call: async (contract: ConnectedContract, action: string, ...args: unknown[]): Promise<CallResult> => { ... }, state: async (contract: ConnectedContract): Promise<unknown> => { ... }, ledgerState: async (contract: ConnectedContract): Promise<unknown> => { ... }, effect: { call: callEffect, state: stateEffect, ... },};Usage Pattern
import * as Midday from '@no-witness-labs/midday-sdk';
// Data flows through functionsconst client = await Midday.Client.create(config);const builder = await Midday.Client.contractFrom(client, { module });const contract = await Midday.ContractBuilder.deploy(builder);const result = await Midday.Contract.call(contract, 'increment');const state = await Midday.Contract.ledgerState(contract);Consequences
Benefits
- Immutability by default: Plain interfaces can’t have hidden mutable state
- Easy testing: Functions are pure and take all inputs as arguments
- Tree-shaking friendly: Bundlers can eliminate unused functions
- Explicit data flow: All state is visible in function signatures
- Composability: Functions compose better than method chains
- Effect.ts alignment: Module-functions map naturally to Effect services
Tradeoffs
- Verbosity:
Midday.Contract.call(contract, 'increment')vscontract.call('increment') - Discoverability: IDE autocomplete shows all functions, not just relevant ones
- Familiarity: OOP developers may find the pattern unfamiliar initially
Why Not Classes?
| Concern | Classes | Module-Functions |
|---|---|---|
| Hidden state | Easy to introduce | Impossible (no this) |
| Testing | Requires mocking instances | Pure functions, easy to test |
| Serialization | Complex (methods lost) | Trivial (plain data) |
| Effect.ts | Awkward fit | Natural alignment |
| Tree-shaking | Methods not tree-shakeable | Functions are tree-shakeable |
Pattern Examples
The same pattern is used consistently across the SDK:
// Wallet moduleWallet.init(seed, networkConfig)Wallet.waitForSync(walletContext)
// Providers moduleProviders.create(walletContext, options)
// HttpZkConfigProvider moduleHttpZkConfigProvider.make(baseUrl)HttpZkConfigProvider.getZKIR(provider, circuitId)This consistency makes the SDK predictable and easy to learn.