Skip to main content
Archived design reference — not the current architecture

These docs describe a proposed Simplified Hexagonal Architecture from an internal design exploration that was not adopted. The design principles (entity invariants, contracts, dependency inversion, load → authorize → validate → execute, in-memory-fake testing) remain useful, but the specifics below do not exist in this codebase: the package paths packages/core / packages/infra / packages/shared, the @packages/* import aliases, the main.ts wiring entry point, and the EnableArchitectureTransition feature flag. Do not follow the paths, aliases, or imports here as-is.

Quick Reference for AI Agents

← Back to README


The Core Principle

Your business logic shouldn't know about databases, HTTP, or any external system.


File Path Templates

ComponentPath PatternExample
Entitypackages/core/src/{feature}/{Name}.tspackages/core/src/orders/Order.ts
Repository Contractpackages/core/src/{feature}/{Name}Repository.tspackages/core/src/orders/OrderRepository.ts
Policiespackages/core/src/{feature}/{Name}Policies.tspackages/core/src/orders/OrderPolicies.ts
User Actionpackages/core/src/{feature}/actions/{verbNoun}.tspackages/core/src/orders/actions/createOrder.ts
System Actionpackages/core/src/{feature}/actions/system{VerbNoun}.tspackages/core/src/orders/actions/systemExpireOrders.ts
Querypackages/core/src/{feature}/queries/{getOrList}{Noun}.tspackages/core/src/orders/queries/getOrderDetails.ts
Feature Indexpackages/core/src/{feature}/index.tspackages/core/src/orders/index.ts
Shared Contractpackages/core/src/shared/{Name}.tspackages/core/src/shared/Mailer.ts
Shared Errorspackages/core/src/shared/errors.tspackages/core/src/shared/errors.ts
AuthContextpackages/core/src/shared/authorization/AuthContext.ts-
Infra Implementationpackages/infra/src/{feature}/{Name}Repository{Provider}.tspackages/infra/src/orders/OrderRepositoryMongo.ts
Infra Sharedpackages/infra/src/shared/{technology}/{Name}.tspackages/infra/src/shared/email/SendGridMailer.ts
Infra Test Fakepackages/infra/src/{feature}/memory/InMemory{Name}.tspackages/infra/src/orders/memory/InMemoryOrderRepository.ts
Handlerapps/client/pages/api/{feature}/{endpoint}.tsapps/client/pages/api/orders/index.ts
Validatorpackages/shared/src/validation/{feature}.tspackages/shared/src/validation/orders.ts

Import Rules

Allowed Imports

apps/client/pages/api/ → @packages/core, @packages/infra, @packages/shared
apps/client/server/ → everything
apps/client/ → @packages/shared (for frontend components)
@packages/core → @packages/shared
@packages/infra → @packages/core, @packages/shared

Forbidden Imports

@packages/core → @packages/infra ❌ NEVER
@packages/core → apps/ ❌ NEVER
@packages/infra → apps/ ❌ NEVER
@packages/infra/{feature} → @packages/infra/{other-feature} ❌ NEVER
@packages/shared → @packages/core ❌ NEVER
@packages/shared → @packages/infra ❌ NEVER
@packages/shared → apps/ ❌ NEVER

Cross-Feature Rule

@packages/core/src/orders → @packages/core/src/customers ❌ NEVER import directly

Use function dependencies instead (for both reading AND writing). See Cross-Feature Communication.


Infrastructure Placement Rule

Follow the contract location:

Contract Location → Implementation Location
─────────────────────────────────────────────────────────────
core/shared/Mailer.ts → infra/shared/email/SendGridMailer.ts
core/orders/OrderRepository.ts → infra/orders/OrderRepositoryMongo.ts
Contract InImplementation In
core/shared/infra/shared/{technology}/
core/{feature}/infra/{feature}/
Infrastructure plumbinginfra/shared/{technology}/
Test fakesinfra/{feature}/memory/

When to Use core/shared/

Decision Guide

QuestionYes →No →
Is it used by 2+ features?core/shared/Keep in feature
Is it a cross-cutting concern? (auth, logging, errors, transactions)core/shared/Keep in feature
Does every action need it? (e.g., AuthContext)core/shared/Keep in feature

What Belongs in core/shared/

CategoryExamplesWhy Shared
AuthorizationAuthContext.tsEvery user action needs auth context
Cross-cutting servicesMailer.ts, Logger.tsMultiple features send emails/log
Error typeserrors.tsConsistent error handling across features
Transaction handlingTransactionManager.tsAny feature may need atomic operations
Common patternsResult.tsStandardized return types across actions

Migration Rule

Start in feature, promote to shared when needed.

Don't preemptively put contracts in core/shared/. See Contracts - When to Use Shared for detailed guidance.


Lightweight Contract Pattern

When you only need to read data from another domain, define a minimal repository interface:

// core/agents/AgentRepository.ts
export interface AgentReadData { id: string; name: string; /* ... */ }
export interface AgentRepository {
findByIds(ids: string[]): Promise<AgentReadData[]>;
}

No need for a full entity — a read-only DTO + repository contract is sufficient.


System vs User Action Decision

If the request originates from...Use...
API route, queue processing a user requestUser action (deps, ctx, input)
Cron job, system maintenanceSystem action (deps, input)

Action Signatures

User Action (called by API handlers, CLI with auth)

export async function {actionName}(
deps: {ActionName}Deps, // 1. Dependencies (repositories, services)
ctx: AuthContext, // 2. Auth context (who's calling)
input: {ActionName}Input // 3. Action-specific data
): Promise<{ReturnType}>

System Action (called by workers, schedulers, migrations)

export async function system{ActionName}(
deps: {ActionName}Deps, // 1. Dependencies (repositories, services)
input: {ActionName}Input // 2. Action-specific data (NO ctx)
): Promise<{ReturnType}>

Action Pattern

1. LOAD → Fetch required data from repositories
2. AUTHORIZE → Check permissions using policies (user actions only)
3. VALIDATE → Business validation (DB lookups, cross-feature checks)
4. EXECUTE → State changes via entity methods
5. PERSIST → Save to repository
6. SIDE EFFECTS → Emails, notifications, analytics (non-critical)

Error Types

ErrorThrown ByHTTPWhen to Use
NotFoundErrorAction404Resource doesn't exist in database
BusinessErrorAction422Business rule prevents operation
InvariantErrorEntity422Invalid state transition attempted

For error class definitions and handling patterns, see Error Handling.


Feature Types Decision

QuestionYes →No →
Does it OWN data that needs persistence?Resource FeatureContinue ↓
Does it COORDINATE multiple features?Orchestration FeatureContinue ↓
Does it CALCULATE or TRANSFORM data?Computation FeatureNot a separate feature

What Each Type Needs

Feature TypeEntityRepositoryPoliciesActions
Resource✅ Yes✅ Yes✅ Yes✅ Yes
Orchestration❌ No❌ NoOptional✅ Yes (with function deps)
Computation❌ No❌ NoOptional✅ Yes (pure functions)

Where to Put Logic

Put in EntityPut in Action
State transitions (submit(), cancel())Authorization (policy checks)
State validation (canCancel())Database operations (load, save)
Computed properties (get total())External API calls
Invariants (throw InvariantError)Side effects (email, analytics)
Core calculationsBusiness validation requiring DB

Rule of thumb: If it needs await, it goes in Action.


Naming Conventions

Files

TypePatternExample
Entity{Name}.ts (PascalCase, singular)Order.ts
Contract{Name}Repository.tsOrderRepository.ts
User Action{verbNoun}.ts (camelCase)createOrder.ts
System Actionsystem{VerbNoun}.tssystemExpireOrders.ts
Feature Infra{Name}Repository{Provider}.tsOrderRepositoryMongo.ts
Shared Infra{Name}{Provider}.tsSendGridMailer.ts
Test FakeInMemory{Name}.tsInMemoryOrderRepository.ts
Validator{feature}.tsorders.ts

Code

TypePatternExample
Entity classPascalCaseclass Order
InterfacePascalCaseinterface OrderRepository
Action functioncamelCasefunction createOrder()
Error classPascalCase + Errorclass NotFoundError
TypePascalCasetype OrderStatus

Validation Layers

LayerLocationValidatesFails With
InputAPI HandlerData shape, format, types400 Bad Request
BusinessActionDB lookups, permissions, rules404 / 422
InvariantEntityState transitions422

Dependencies Interface Pattern

// For single-feature actions
export interface CreateOrderDeps {
repository: OrderRepository;
mailer: Mailer;
}

// For cross-feature actions (use function dependencies - both read AND write)
export interface CreateOrderDeps {
repository: OrderRepository;
mailer: Mailer;
// Read from other features
getCustomer: (customerId: string) => Promise<CustomerData | null>;
checkStock: (productId: string, quantity: number) => Promise<StockResult>;
// Write to other features - equally valid!
updateQuest: (questId: string, data: QuestUpdateData) => Promise<void>;
}

Important: Define data interfaces (CustomerData, StockResult, QuestUpdateData) in the action file, not imported from other features. Function dependencies work for both reading and writing cross-feature data.


AuthContext & Policies

For AuthContext definition and policy patterns, see Authorization.


Policy Pattern

// packages/core/src/orders/OrderPolicies.ts
export const OrderPolicies = {
canCreate(ctx: AuthContext): boolean {
return ctx.roles.includes('customer') || ctx.isAdmin;
},

canCancel(ctx: AuthContext, order: Order): boolean {
return order.customerId === ctx.userId || ctx.isAdmin;
},

canView(ctx: AuthContext, order: Order): boolean {
return order.customerId === ctx.userId || ctx.isAdmin;
},
};

Handler Pattern (Next.js Pages API)

// apps/client/pages/api/orders/index.ts
import { baseApi } from '@server/middlewares/baseApi';
import { createOrderSchema } from '@packages/shared/validation/orders';
import { createOrder } from '@packages/core/orders';

const handler = baseApi({ auth: true })
.post(async (req, res) => {
// 1. Validate input
const parsed = createOrderSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}

// 2. Call action
const order = await createOrder(deps, req.ctx, parsed.data);
res.status(201).json({ id: order.id });
});

export default handler;

Wiring Dependencies in main.ts

Dependencies are constructed once at startup in the middleware/server setup, not in handlers.

// apps/client/server/middlewares/baseApi.ts or similar setup
// Create dependencies (can also be done in middleware factory)
const deps = {
repository: new OrderRepositoryMongo(),
mailer: new SendGridMailer(process.env.SENDGRID_API_KEY!, 'orders@myapp.com'),
};

// For cross-feature deps, create factories that bind AuthContext
const createOrderDeps = (ctx: AuthContext) => ({
repository: orderRepository,
mailer,
getCustomer: async (id: string) => {
const c = await getCustomer(customerDeps, ctx, { customerId: id });
return c ? { id: c.id, email: c.email, isInGoodStanding: c.status === 'active' } : null;
},
});
RuleWhy
Wire at startupShared connection pools, fail-fast on bad config
Inject, don't constructHandlers receive deps, don't create them
Use factories for cross-featureBinds AuthContext to function dependencies

For complete examples, see Entry Points - Wiring Dependencies.


Quick Checklist: New Resource Feature

  1. packages/core/src/{feature}/{Name}.ts - Entity
  2. packages/core/src/{feature}/{Name}Repository.ts - Contract
  3. packages/core/src/{feature}/{Name}Policies.ts - Authorization
  4. packages/core/src/{feature}/actions/{verb}{Name}.ts - Actions
  5. packages/core/src/{feature}/index.ts - Exports
  6. packages/infra/src/{feature}/{Name}Repository{Provider}.ts - Implementation
  7. packages/infra/src/{feature}/memory/InMemory{Name}Repository.ts - Test fake
  8. packages/shared/src/validation/{feature}.ts - Input validation
  9. apps/client/pages/api/{feature}/index.ts - HTTP handlers
  10. Wire dependencies in handler or middleware

TopicFile
Tutorial: Build a feature18-tutorial-building-products.md
Core concepts01-core-concepts.md
Feature types02-feature-design.md
Entities03-domain-objects.md
Contracts04-contracts.md
Actions05-actions.md
Entry points & wiring06-entry-points.md
Infrastructure07-infrastructure.md
Rules08-rules-and-guidelines.md
Logic placement03-domain-objects.md
Authorization09-authorization.md
Validation10-validation.md
Testing15-testing.md
Error handling16-error-handling.md