The Problem
Every time MACHBANK’s SDK needed a UI change — a new step in a payment flow, a reordered screen, different button copy — the change required a full iOS/Android/Web release cycle. App store reviews, version fragmentation, and adoption lag meant even trivial product experiments took 4–6 weeks to reach users.
The root cause was architectural: business logic lived in mobile clients. If the server decided what data to show and clients decided how to render it, changing the “what” required changing the clients.
The fix was to invert the model: the server sends a description of the complete UI, and clients are generic renderers that never change for business reasons.
Architecture: Two Layers
The framework is split into two published npm packages, each with a distinct responsibility.
Layer 1: @soymach/mach-sdui-lib — The Component Library
This is the TypeScript library that BFF services use to compose UI responses. It defines the full vocabulary of MACHBANK’s UI: every component, layout, action, and interaction, as strongly-typed factory functions that produce plain JSON objects.
Component system
The library defines 26 UI components organized as molecules and organisms — Button, Alert, CardDetailBox, ListItemSelect, BottomSheetDetail, ReceiptHeader, and more. Every component is created via a factory function, never a class instance:
// A button with a remote navigation action
Components.Button({
text: 'Confirm payment',
class: ButtonClassName.Primary,
action: Actions.NavigateRemote({
source: 'POST_/payments/confirm',
flowName: 'paymentFlow',
loader: Loader({ type: LoaderType.Spinner }),
onComplete: Actions.NavigateLocal({ flowName: 'receiptFlow' }),
onError: Actions.RetryFlow(),
}),
})
Factory functions return plain objects — fully JSON-serializable, with no prototype chain or class overhead. Clients don’t need to know how components were created; they just render what they receive.
Layouts, sections, and screens
UI is composed in three layers:
- Sections (
ButtonSection,HeroSection,DetailSection, etc.) group related components into named containers with auto-generated UUIDs - Layouts (8 types:
LayoutComplete,LayoutWithoutNav,LayoutBottomSheetComplete, etc.) slot sections intonav,main, andfooterplacements - Screens hold a layout plus optional navigation behavior and analytics events
Actions and chaining
13 action types handle every user interaction: NavigateRemote, NavigateLocal, CallableRemote, OpenModal, OpenBottomSheet, UpdateContext, Close, RetryFlow, and more. Actions are composable — any action can chain to a next action on completion or error:
Actions.CallableRemote({
source: 'POST_/payments/authorize',
data: { amount: stateRefs.amount },
onComplete: Actions.NavigateRemote({ source: 'GET_/payments/receipt', ... }),
onError: Actions.OpenBottomSheet({ flowName: 'errorSheet' }),
})
State binding
Dynamic content is bound through state refs — typed string literals that clients interpolate at render time:
const { state, stateRefs } = State({ amount: 15000, currency: 'CLP' })
Components.Button({ text: `Pay ${stateRefs.amount} ${stateRefs.currency}` })
// Serialized → { text: "Pay {state.amount} {state.currency}" }
// Client renders → "Pay 15000 CLP"
The IStateRef type is defined as `{state.${string}}`, so TypeScript enforces valid state references at compile time.
Build-time validation
The build() function is the final step before serialization. It:
- Auto-extracts all sections from layout placements — no manual
sectionsarray - Asserts all section IDs are unique (throws on collision)
- Cross-validates that every referenced section ID exists, and every defined section is used
This means malformed SDUI responses are a compile + runtime error caught locally, never a client-side crash in production.
Layer 2: @soymach/mach-bff-sdk — The Server Infrastructure
This is the Fastify-based SDK that BFF services use to serve SDUI responses. It handles the protocol layer: version negotiation, request parsing, schema validation, trace propagation, and analytics.
Header-based protocol versioning
Clients send X-SDUI-Protocol-Min and X-SDUI-Protocol-Max headers with each request. The SDK negotiates the richest supported version within that range:
Client sends: X-SDUI-Protocol-Min: 1.0 / X-SDUI-Protocol-Max: 2.0
Server supports: ['1.0', '2.0', '3.0']
Negotiated version: 2.0 (highest in range)
If no version matches, the SDK returns 406 Not Acceptable. This decouples mobile app versions from backend deployments — old clients continue receiving their supported protocol version while new features ship to newer clients, without any routing changes.
Type-safe route definition
Routes are declared explicitly via defineRoutes, which enforces the METHOD_/path naming convention at compile time:
const routes = defineRoutes({
'POST_/payments/create': {
handler: { '1.0': createPaymentPresenter },
schemas: { input: { body: paymentSchema } },
},
'GET_/payments/receipt': {
handler: { '1.0': receiptPresenter },
schemas: { input: {} },
},
})
The type system ensures handlers declare which protocol versions they support ({ '1.0': fn }), making it impossible to accidentally omit a version. Path extraction is also type-safe — routes.path('GET_/payments/receipt') produces the correct string and errors at compile time on any typo.
Automatic common fields
Every endpoint automatically inherits documentNumber (Chilean RUT), channelCode, machId, and platform in its query schema. Consumer services don’t redeclare them — the SDK merges them, with consumer-defined properties taking precedence on conflict.
Impact
The framework eliminated mobile release cycles for all UI-driven features in the MACHBANK SDK. A UI change that previously required a 4–6 week mobile release cycle now ships with a backend deployment.
The project won MACHBANK’s EPIC Project Award and the ‘Achieving Ambitious Goals’ award.