Skip to main content
Why the code is arranged the way it is, and what each boundary buys.

The shape

The contracts package is the load-bearing part. Both sides import it, so a change to a request or response shape is a compile error on both sides at once, rather than a disagreement discovered in production.

Why hexagonal on the API

Each feature module is arranged as:
Dependencies point inward. A use case declares what it needs as a port; the infrastructure layer implements it and the module wires the two together in one place. Three things follow:
  • The domain is testable without a database. Segment matching and campaign status rules are pure functions over their own types.
  • The seed script and a future job reuse the use cases, not a parallel implementation. customers.import is the same code whether a person or a scheduled task triggers it.
  • Framework types stop at the controller. A Repository from TypeORM never reaches a use case, so swapping persistence does not reach into business rules.

Where composition happens

app.module.ts is the only place that knows every module exists. A feature module never imports another, which is what keeps the dependency graph acyclic and readable.

Why the contract is generated into OpenAPI

The alternative — @nestjs/swagger decorators on controllers — describes the same facts a second time, in a place the compiler does not check against the client. The two inevitably disagree. Generating the specification from the registry means the published reference cannot describe an endpoint that does not exist, or omit a field the API accepts. The rendered UI is Swagger UI, served from the installed package rather than a CDN: the reference has to work on an isolated network, and a third party must not be able to change what a reader is shown. See the shared contract.

Why the version is in the path

/api/v1/customers rather than a header, a media type, or no version at all. See versioning.

Why responses are validated on arrival

The client validates every response against the contract before handing it to the application. This costs a parse per response and buys something specific: a deployment mismatch — a new API version, a proxy rewriting a body, a stale cached bundle — fails loudly at the boundary instead of producing undefined three layers into a component.
Loud and early is the intent. A silent cast here would move the failure to the one place that cannot explain it.

Why the model runs server-side

The browser cannot hold a provider key, and a prompt is business logic that belongs under version control. The API owns a model port with four implementations: the Anthropic adapter, the Gemini adapter, the OpenCode Zen adapter, and a deterministic local generator. One decision made at boot binds either port, so tests and development are offline and repeatable while production gets a hosted model. Which adapter is bound comes from configuration alone — adding a key is the only step needed to change models. See the generative pipeline, and add an LLM provider for the steps a new provider follows.

Why state is split between Zustand and TanStack Query

They answer different questions.
  • Zustand owns client state that no request produces: whether a session exists, which tokens are held. The HTTP client reads it outside React, because it needs a token on every call.
  • TanStack Query owns anything that came from the server: customers, segments, campaigns, KPIs.
The practical consequence is that a write invalidates a cache rather than notifying a component. Importing customers refreshes the table, the KPIs and the dashboard because all three read the same keys — with no event bus to keep in sync. See client state. The one thing that is neither is a form draft: local component state, unsaved and unshared.

What is deliberately absent

  • A service layer between controller and use case. It would only forward arguments.
  • Interfaces generated by hand for responses. The registry entry is the type.
  • A mock database in the API test suite. The port is faked at the test boundary instead, so tests stay fast without pretending persistence is optional.
Last modified on October 6, 2026