Skip to main content

Tiers

Every workspace member carries exactly one tier tag in its turbo.jsonc. The tag matches its directory. The direction is one way. A package never imports an app, a service, or encore.dev. An app never imports a service’s code. A package never imports another package’s src/: it uses the package’s declared exports. See ADR 0003 and the package standard. Enforcement: enforced. Turborepo boundaries tags in turbo.jsonc, the oxlint no-restricted-imports rules in packages/tooling/oxlint/src/boundaries.jsonc, and the tier check in tools/scripts/validate-repository.mjs. All run in pnpm verify.

Placement test

Where does new code go?
  1. Would a second, unrelated product still need this verbatim? Yes: it is a package in packages/<category>/.
  2. Does it only compose packages into something that runs or ships? A deployable: a service in services/<name> or an app in apps/<name>.
  3. Would it need renaming to make sense elsewhere? It belongs in the app or service that owns it. It is not a package.
Pick the package category from the structure guide. Do not add a category for one package. Enforcement: planned. The package-steward and architecture-guardian agents review every new member. No script can decide placement.

Shared packages hold mechanisms, apps hold definitions

A package never contains a product enumeration or literal: no brand name, locale list, project id, URL, tenant id or environment value. It exposes decorators, base classes and typed config contracts; there is no define*() API (0094). The definitions (decorated classes and config objects) live in the app or service that owns them. A convenience value such as a default locale list is fine. A closed union type of product names is not. A package never reads process.env. Configuration reaches it as an argument or a typed config object that the app builds. Enforcement: partial. FORBIDDEN_DEPENDENCIES in the validator blocks forbidden packages. A check for product literals and process.env in packages/**/src is planned (a validator rule or an oxlint rule). Until then a literal in a shared package is a review finding.

Service ownership

Services are independent workspace members: services/<name> is @figentra/service-<name> with entry src/main.ts. Service source is plain TypeScript; Encore is the generated runtime target in .stackra/encore/ (0091, 0124). Until stackra gen exists, the rules below describe the target. They apply from the first service that owns data. A service owns its API, its database, its schema and migrations, its events, its tests, a README.md and an AGENTS.md with a PII table. No other service reads that database or imports that service’s internals. Enforcement: partial. The validator checks the service-unit files. Cross-service import and database-read rules are planned with the first service that has a database.

What a contract is

A shared-types folder is not a contract. A contract is a package’s types-only ./contracts entry: a stable boundary with a version and a change policy, with no behaviour and no runtime dependency. Shared primitives (PublicId, Actor, EventEnvelope, Page, errors) live in @stackra/contracts. There are no separate <domain>-contracts packages, and cross-service types live in a contract and nowhere else (0117).

Not built yet

Do not introduce a technology on the rejected or deferred list “to be helpful”. If a task seems to need one, stop and route to the architecture-guardian. The list and each revival trigger live in ADR 0011. Enforcement: partial. The forbidden-dependency list in the validator.

History

  • 2026-10-20, 0125: factories became decorators (0094); contract packages became ./contracts entries and @stackra/contracts (0117).