Business Context
Scotiabank CCAU's OneBank initiative aimed to create a unified digital onboarding experience across all retail banking products in the Chilean market. The premise: customers should be able to apply for a loan, credit card, savings account, or insurance product through a consistent experience, with shared verification flows and a common regulatory compliance surface.
The engineering challenge was organizational as much as technical. Four product teams — Loans, Credit Cards, Accounts, and Insurance — were genuinely independent units with different business owners, different compliance requirements, and different deployment timelines. Any shared infrastructure had to accommodate real team autonomy. Centralized control was not an option; the platform had to work by making the right thing easy, not by enforcing a single deployment path.
Problem Space
Before the OneBank platform, the state was:
- Four separate form implementations with independently maintained validation logic that had diverged over time
- Regulatory rule changes (income thresholds, age restrictions, required document types) required four coordinated deployments — one per product — with no shared rollback strategy
- Design system updates required four separate implementation efforts with no guarantee of visual consistency across products
- No shared type system for cross-product data contracts: product codes, customer identifiers, and regulatory fields were string-typed and informally coordinated
- Developer onboarding required learning four different architectural patterns for solving structurally identical problems
The deeper problem was ownership ambiguity. When a compliance team identified a regulatory rule change, who owned propagating it? The answer was "everyone" — which in practice meant inconsistent implementation timing and inconsistent behavior between products during the window when some had updated and others hadn't.
The platform's job was to encode ownership clearly: regulatory validation rules live in one place, are tested in one place, and are deployed to all products through a shared dependency update.
System Constraints
- Independent team deployability: Each product team must be able to deploy without coordinating with the others. A shared component update cannot block a product release.
- Regulatory auditability: Chilean banking regulation requires that the state of validation logic at the time of form submission must be reconstructable. The version of the validation rules applied to a submitted application must be verifiable after the fact.
- Jurisdictional variability: If the platform extended to Australia or Canada, validation rules would differ by jurisdiction. The architecture needed to accommodate this without structural changes to the form components.
- Strict semver: Shared library updates couldn't break existing product teams without explicit migration paths. This meant the public API surface of shared packages was a first-class concern, not an afterthought.
- Framework-independent business logic: Business rule tests must run in CI without a browser or React renderer. Validation logic couldn't be coupled to component rendering.
Architecture
The platform is a 3-layer monorepo with independent versioning per package. Each layer has a distinct responsibility and a distinct rate of change.
The key insight behind the 3-layer split: visual evolution (design changes) and structural contracts (API surface) have different rates of change and different downstream impact. Coupling them in a single package forces consumers to treat every design update as a potential breaking change. Separating them lets the design system evolve without API friction.
The forms layer is where the most important architectural decision lives: all business logic is isolated from React. resolverConfigFormulario(formState, regulatoryContext) is a pure function — no hooks, no side effects, no React dependency. It can be called in Node.js, in a test runner, in a browser. This was the prerequisite for fast, framework-independent business rule tests that run in under 2 seconds.
Architecture Decision Records
Monolithic component libraries couple visual evolution to API stability. Design changes propagate as breaking changes to all consumers. Four product teams cannot coordinate design system updates simultaneously — they deploy on independent timelines.
Separate into: @onebank/primitives (headless, API-stable, no styles), @onebank/components (visual layer, can evolve without API breakage), and @onebank/forms (business logic, pure functions, Zod schemas).
Cross-layer changes require staged releases: primitives → components → forms → product teams. This coordination cost is real and required the codemod tooling to make major version migrations manageable. The benefit: teams using @onebank/primitives directly never experienced visual regression from design system updates.
Form validation was duplicated across four product frontends and the BFF layer. Validation rules diverged over time with no single authoritative definition. Regulatory changes required coordinated updates across all implementations.
All validation schemas live in @onebank/schemas, exported via @onebank/forms. The BFF uses the same schemas for server-side validation. The schema package has zero UI dependency — it can be imported from Node.js without React in scope.
Schema updates require coordinated deployment of the BFF and at least one frontend. This is a real coordination cost, but it's explicit and bounded — schema changes are intentional events with clear migration paths, not accidental divergence.
The form has conditional field requirements based on applicant type (persona natural / persona jurídica). Initial implementation used optional fields with runtime type guards. TypeScript types were too permissive — invalid persona/field combinations compiled without error.
z.discriminatedUnion('tipoPersona', [PersonaNaturalSchema, PersonaJuridicaSchema]). TypeScript narrows the type correctly in conditional branches. Accessing rut on a juridica applicant is a compile error, not a runtime undefined.
The schema structure must align with the discriminant field in the UI. Adding a new persona type is additive — no modification to existing schemas required. Changing how tipoPersona is handled requires updating all schemas. This is the right constraint: persona type is a fundamental domain concept, not an incidental field.
Regulatory field visibility and required-ness rules were scattered across component implementations. Testing them required rendering React components in a browser-like environment — slow, fragile, and requiring infrastructure that shouldn't be necessary for business rule validation.
Extract field configuration to a pure function: resolverConfigFormulario(formState: Partial<FormValues>, regulatoryContext: RegulatoryContext): FormConfig. No React. No hooks. No async. The function returns a configuration object that UI components consume — they do not make visibility decisions themselves.
Business logic tests run in under 2 seconds without browser infrastructure. The function must be called explicitly on each form state change via form.watch(). This is verbose but transparent — every call site is visible, there are no hidden subscriptions.
Tradeoffs Considered
Monolithic library vs. 3-layer separation
- Simpler to maintain, single release coordination
- No cross-layer staging overhead
- Visual and structural changes in one PR
- Design changes propagate as potential breaking changes to all consumers
- Teams cannot update visual layer without risking API surface
- Visual evolution decoupled from API stability
- Teams using primitives directly never regress from design changes
- Regulation changes deploy through a single schema update
- Cross-layer changes require staged releases
- Higher coordination overhead — mitigated by codemod tooling
Renovate auto-merge vs. manual updates
Manual library update processes create version drift — we observed this acutely in year 1, when Seguros fell two major versions behind and was effectively maintaining a fork. Automated minor/patch updates via Renovate with a 1-sprint window for majors keeps teams current without creating emergency migration debt. The prerequisite: rigorous semver discipline. patch must mean "no behavior change" — not "mostly no behavior change". This required a cultural shift in how we thought about releases.
Event sourcing vs. event logging for audit
Regulators require reconstructibility of form state at submission time — not necessarily full event sourcing. We chose event logging: field changes, validation results, and submission outcomes are recorded with the form version active at that moment (versionFormulario). The simpler model is easier to query and audit. The cost: we cannot replay events to reconstruct arbitrary intermediate form states — only the explicitly logged states. For compliance purposes, this was sufficient. For future behavioral analysis use cases, it would not be.
Scalability Challenges
The version drift problem was the most acute scaling challenge. At peak drift, Seguros was two major versions behind @onebank/components. This created a compounding tax: any bug fix in the current version needed manual backporting to v2.8.0 for Seguros. Security patches in v4 were unavailable to a product team that couldn't afford the migration capacity.
The codemods addressed 80% of the mechanical migration work — automated find-and-replace for renamed props, restructured imports, and changed API signatures. The remaining 20% was genuinely structural: components used in ways the codemod couldn't safely transform. That 20% required 2-3 days of a product team engineer's time per major version migration.
The insight that came late: codemods should be written alongside breaking changes, not in response to migration difficulty. Writing the codemod as part of the v4 development process would have produced a more complete and better-tested tool. Instead, it was written reactively, and Seguros had already spent a week on manual migration before we realized the pattern was automatable.
Developer Experience
The metric we tracked: time for a new developer to produce a functional, compliant, on-brand form page. This is the right DX metric for a component library. "Number of components published" is not.
Before the OneBank platform, this metric was effectively unmeasurable because there was no standard approach. Developers spent the first weeks of a project reading four different codebases and making architectural decisions that should have been made once.
The benchmark was set concretely: a developer from the Seguros team, familiar with the component library but new to the insurance onboarding forms, built a functional, compliant, on-brand new insurance form page in 4 hours. The components handled visual consistency, the schemas handled validation, and the regulatory context provider handled jurisdictional rules. The developer wrote product logic and UI composition — nothing else.
Cross-team reuse followed the same pattern. When the Insurance team needed form components that already existed in the Loans implementation, they imported @onebank/forms and had working, validated, compliant components in 2 days — against a baseline estimate of 2 weeks for an independent implementation.
Lessons Learned
- API surface area is the most consequential architectural decision in a shared library. Every public export is a commitment. The discipline of treating the public API surface as a contract — with explicit changelog entries for every change — changed how we designed components. We defaulted to private, promoted to public only when necessary.
- Audit requirements are a first-class architectural concern. We added
EventoFormulariologging late, which forced wrappingresolverConfigFormulariocalls with event emission — a leaky abstraction. An event-first form state model from day one would have been cleaner and better tested. - Codemods before breaking changes, not after. Writing migration tooling as part of the breaking change development — not in response to migration complaints — produces better tooling and shifts the cost to the library team, where it belongs.
- The primitives layer paid for itself consistently. Teams that built custom visual experiences on
@onebank/primitivesnever had visual regression issues from design system updates. The investment in a stable headless layer was the right call even when it added initial development overhead. - Organizational independence requires technical independence. The reason the 3-layer architecture worked was that it encoded the organizational reality: four teams that cannot synchronize deployments need dependencies that don't require deployment synchronization. Architecture that ignores org structure will eventually be circumvented by it.
What I'd Improve Today
Contract testing between BFF and frontend schemas. Currently, schema version compatibility is managed through coordination and semantic versioning. Consumer-driven contract tests (e.g., Pact) would make schema mismatches visible before deployment rather than at runtime. The cost of a schema mismatch in production — a submission that passes frontend validation but fails at the BFF — is high enough to justify the tooling investment.
TypeScript strict mode from day one. The schemas are rigorously typed, but some form component props used looser typing that allowed invalid combinations. Enabling strict: true at project start would have caught several categories of bugs at compile time rather than in QA.
Standardize the regulatory context injection pattern. Over time, products injected regulatory context differently: some via React context provider, some via explicit prop threading, one via a custom hook. This divergence made the pattern harder to reason about and harder to test consistently. A single, enforced injection pattern from the start would have prevented this drift.
Invest in the Renovate pipeline earlier. We spent approximately three months with growing version drift before we addressed it systematically. The tooling was available from the start — the delay was organizational (teams didn't see library updates as their responsibility). Making library currency a team norm from day one, backed by automated tooling, would have avoided the accumulation problem entirely.