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.

Rules & Guidelines

← Back to README

This document contains the architectural rules that MUST be followed. Key rules: Core knows nothing about infra/api, dependencies are interfaces, wire at startup. Import direction flows: apps → packages/infra → packages/core → packages/shared.


The 3 Core Rules

RuleWhat It Means
Core knows nothingNo imports from infra/ or api/ in core/
Dependencies are interfacesCore depends on contracts, not implementations
Wire at startupConnect implementations to core in main.ts or middleware

Import Direction

✅ Allowed:
apps/client/pages/api/ → @packages/core, @packages/shared
apps/client/pages/api/ → @packages/infra
apps/client/server/ → everything
apps/client/src/ → @packages/shared (only)
@packages/core → @packages/shared
@packages/infra → @packages/core, @packages/shared
@packages/infra/{feature} → @packages/infra/shared

❌ Forbidden:
@packages/core → @packages/infra
@packages/core → apps/
@packages/infra → apps/
@packages/infra/{feature} → @packages/infra/{other-feature}
@packages/shared → @packages/core, @packages/infra, apps/

Visual Representation

┌─────────────────┐
│ apps/client/ │
│ server/ │
└────────┬────────┘
│ wires everything
┌──────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ apps/client/ │ │ packages/infra/ │ │ packages/core/ │
│ pages/api/ │ │ │ │ │
└───────┬───────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
│ │ │
└────────────────────┴─────────────────────┘
can import from


┌─────────────────────┐
│ packages/shared/ │ ← No internal deps
│ (API types, Zod) │
└─────────────────────┘


┌──────────┴──────────┐
│ apps/client/src/ │
│ (frontend) │
└─────────────────────┘

Third-Party Libraries in Core

Not all external packages are "infrastructure." Distinguish between:

CategoryExamplesNeeds Contract?Use in Core?
Pure Utilitiesdayjs, lodash, uuid, zodNo✅ Yes
Infrastructuremongoose, sendgrid, stripe, aws-sdkYes❌ No
Non-DeterministicDate.now(), Math.random(), crypto.randomUUID()RecommendedVia contract

Pure Utilities: Use Directly

Libraries that are pure functions (no I/O, no side effects, deterministic) can be imported directly in packages/core/:

// packages/core/src/orders/Order.ts
import dayjs from 'dayjs';
import { v4 as uuid } from 'uuid';

export class Order {
get formattedDate(): string {
return dayjs(this.createdAt).format('YYYY-MM-DD');
}

get daysUntilExpiry(): number {
return dayjs(this.expiresAt).diff(dayjs(this.createdAt), 'days');
}
}

Why this is allowed:

  • No I/O or external system calls
  • Deterministic (same input → same output)
  • No reason to swap implementations
  • Testing doesn't require mocking

Non-Deterministic Functions: Abstract with Contract

Functions that return different values each call should be abstracted for testability:

FunctionProblemSolution
new Date() / dayjs()"Now" changesClock contract
Math.random()Non-deterministicRandomGenerator contract
crypto.randomUUID()Non-deterministicIdGenerator contract

Clock Contract

// packages/core/src/shared/Clock.ts
export interface Clock {
now(): Date;
}
// packages/infra/src/shared/clock/SystemClock.ts
export class SystemClock implements Clock {
now(): Date {
return new Date();
}
}

// packages/infra/src/shared/clock/FakeClock.ts (for testing)
export class FakeClock implements Clock {
constructor(private currentTime: Date = new Date()) {}

now(): Date {
return this.currentTime;
}

advance(ms: number): void {
this.currentTime = new Date(this.currentTime.getTime() + ms);
}
}

IdGenerator Contract

// packages/core/src/shared/IdGenerator.ts
export interface IdGenerator {
generate(): string;
}
// packages/infra/src/shared/id/UuidGenerator.ts
import { v4 as uuid } from 'uuid';

export class UuidGenerator implements IdGenerator {
generate(): string {
return uuid();
}
}

// packages/infra/src/shared/id/FakeIdGenerator.ts (for testing)
export class FakeIdGenerator implements IdGenerator {
private counter = 0;

generate(): string {
return `test-id-${++this.counter}`;
}
}

Library Configuration

Configure plugins, locales, or global settings at the entry point, not in core:

// apps/client/server/setup.ts or similar
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';

// Configure once at startup
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.tz.setDefault('America/New_York');
Configuration TypeLocation
Library pluginsEntry point setup
Default localesEntry point setup
Global settingsEntry point setup

Decision Flowchart

Is it a pure function (no I/O, deterministic)?
├── YES → Use directly in core
└── NO → Does it fetch current time, generate random values, or call external systems?
├── Current time/random → Create contract in core/shared/, implement in infra/shared/
└── External system (DB, API, email) → Create contract, implement in infra/

Common Libraries Reference

LibraryCategoryUse in Core?
dayjsPure utility✅ Direct (except dayjs() for "now")
lodashPure utility✅ Direct
zodPure utility✅ Direct
uuidPure utility✅ Direct
date-fnsPure utility✅ Direct
decimal.jsPure utility✅ Direct
mongooseInfrastructure❌ Contract required
axiosInfrastructure❌ Contract required
@sendgrid/mailInfrastructure❌ Contract required

Folder Responsibilities

FolderContainsCan Import From
packages/shared/API types, validation schemas, constantsExternal libs only
packages/core/Domain objects, actions, contracts@packages/shared
packages/infra/shared/Shared infra (implements core/shared/ contracts)@packages/core, @packages/shared
packages/infra/{feature}/Feature infra (implements core/{feature}/ contracts)@packages/core, @packages/infra/shared
apps/client/pages/api/HTTP handlers, routes, validators@packages/core, @packages/infra, @packages/shared
apps/client/server/Server utilities, middlewareEverything
apps/client/src/Frontend application@packages/shared only

Infrastructure Placement Rule

Follow the contract location - implementation location mirrors contract location:

Contract InImplementation In
core/shared/infra/shared/{technology}/
core/{feature}/infra/{feature}/
Infrastructure plumbinginfra/shared/{technology}/
Test fakesinfra/{feature}/memory/

Naming Conventions

Files

TypePatternExample
Entity{Name}.tsOrder.ts, User.ts
Contract{Name}Repository.tsOrderRepository.ts
Action{verbNoun}.tscreateOrder.ts, cancelOrder.ts
Feature Infra{Name}Repository{Provider}.tsOrderRepositoryMongo.ts
Shared Infra{Name}{Provider}.tsSendGridMailer.ts
Test FakeInMemory{Name}.tsInMemoryOrderRepository.ts
Validator{name}Validators.tsorderValidators.ts

Classes and Functions

TypePatternExample
EntityPascalCase classclass Order
ContractPascalCase interfaceinterface OrderRepository
ActioncamelCase functionfunction createOrder()
ErrorPascalCase + Errorclass NotFoundError

Error Handling

See Validation for error types (NotFoundError, BusinessError, InvariantError) and where each is thrown.


When to Add Complexity

Start simple. Add structure when you feel pain:

PainSolution
Multiple entry points (REST + CLI + Queue)Separate apps/ for each entry point
Complex domain with many entitiesAdd packages/core/src/orders/domain/ subfolder
Shared types between featuresCreate packages/core/src/shared/
Shared types with frontendCreate packages/shared/ (details)
Complex read requirementsAdd queries folder and DTOs (details)
Need explicit use case boundariesExtract use case classes
Complex error handlingCreate packages/core/src/shared/errors.ts
Need transactionsAdd UnitOfWork contract
10+ actions in a featureGroup actions by subdomain
Feature outgrows folderPromote to separate feature

Common Mistakes

1. Putting business logic in API handlers

// ❌ Bad - logic in handler
const handler = baseApi({ auth: true })
.post(async (req, res) => {
const order = new Order(...);
if (order.items.length === 0) {
return res.status(400).json({ error: 'Empty order' });
}
await OrderModel.create({ ... });
await sendEmail(req.body.email, 'Order confirmed');
res.json(order);
});

// ✅ Good - handler calls action
const handler = baseApi({ auth: true })
.post(async (req, res) => {
const parsed = createOrderSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error });
}
const order = await createOrder(deps, req.ctx, parsed.data);
res.status(201).json({ id: order.id });
});

2. Importing infrastructure in core

// ❌ Bad - core knows about mongoose
// packages/core/src/orders/actions/createOrder.ts
import { OrderModel } from '@packages/infra/orders/models/OrderModel'; // NO!

// ✅ Good - core uses interface
// packages/core/src/orders/actions/createOrder.ts
import { OrderRepository } from '../OrderRepository';

3. Putting side effects in entities

// ❌ Bad - entity sends email
class Order {
async submit() {
this.status = 'submitted';
await sendEmail(this.customerEmail, 'Confirmed'); // NO!
}
}

// ✅ Good - action handles side effects
async function createOrder(deps, input) {
const order = new Order(...);
order.submit();
await deps.repository.save(order);
await deps.mailer.send(input.email, 'Confirmed', '...');
}

4. Skipping validation layers

// ❌ Bad - no input validation
const handler = baseApi({ auth: true })
.post(async (req, res) => {
const order = await createOrder(deps, req.ctx, req.body); // Unsafe!
res.json(order);
});

// ✅ Good - validate before calling core
const handler = baseApi({ auth: true })
.post(async (req, res) => {
const parsed = createOrderSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error });
}
const order = await createOrder(deps, req.ctx, parsed.data);
res.json(order);
});

Anti-Pattern Detection Guide

This section provides machine-readable patterns for detecting architectural violations. Each anti-pattern includes detection criteria and remediation steps.

AP-1: Infrastructure Import in Core

Detection Pattern:

File location: packages/core/**/*.ts
Search for: import.*from.*@packages/infra
Search for: import.*from.*mongoose|mongodb|sendgrid|aws-sdk

Violation Example:

// packages/core/src/orders/actions/createOrder.ts
import { OrderModel } from '@packages/infra/orders/models/OrderModel'; // ❌ VIOLATION

Fix: Create a contract interface in core, implement in infra.


AP-2: Business Logic in Handler

Detection Pattern:

File location: apps/client/pages/api/**/*.ts
Indicator: More than 5 lines between input validation and action call
Indicator: Database queries in handler
Indicator: Business conditionals (if customer.status, if order.total)

Violation Example:

// apps/client/pages/api/orders/index.ts
const handler = baseApi({ auth: true })
.post(async (req, res) => {
const customer = await CustomerModel.findById(req.body.customerId);
if (customer.status === 'suspended') { // ❌ Business logic in handler
return res.status(400).json({ error: 'Suspended' });
}
// ...more logic
});

Fix: Move all business logic to action. Handler should only: validate input, call action, return response.


AP-3: Side Effects in Entity

Detection Pattern:

File location: packages/core/src/**/[A-Z]*.ts (Entity files)
Search for: await (inside class methods)
Search for: import.*Mailer|Logger|Repository

Violation Example:

// packages/core/src/orders/Order.ts
class Order {
async submit() {
this.status = 'submitted';
await sendEmail(this.customerEmail, 'Confirmed'); // ❌ Side effect
}
}

Fix: Entity methods should be synchronous. Move side effects to action.


AP-4: Missing Authorization Check

Detection Pattern:

File location: packages/core/src/**/actions/*.ts
Function signature: (deps, ctx, input)
Missing: Policies.can or policy check in function body

Violation Example:

// packages/core/src/orders/actions/cancelOrder.ts
export async function cancelOrder(deps, ctx, input) {
const order = await deps.repository.findById(input.orderId);
// ❌ Missing: OrderPolicies.canCancel(ctx, order) check
order.cancel(input.reason);
await deps.repository.save(order);
}

Fix: Add policy check after loading the entity, before business logic.


AP-5: Cross-Feature Repository Import

Detection Pattern:

File location: packages/core/src/{feature}/**/*.ts
Search for: import.*Repository.*from.*@packages/core/(?!{same-feature})
Search for: import.*from.*\.\.\/\.\.\/(?!shared)

Violation Example:

// packages/core/src/orders/actions/createOrder.ts
import { CustomerRepository } from '../../customers/CustomerRepository'; // ❌ Cross-feature import

Fix: Use function dependencies instead (for both read AND write):

export interface CreateOrderDeps {
// Read from another feature
getCustomer: (id: string) => Promise<CustomerData | null>; // ✅ Function dependency
// Write to another feature - also valid!
updateQuest: (id: string, data: QuestUpdateData) => Promise<void>; // ✅ Function dependency
}

AP-5b: Cross-Feature Infra Import

Detection Pattern:

File location: packages/infra/src/{feature}/**/*.ts
Search for: import.*from.*@packages/infra/(?!shared)(?!{same-feature})

Violation Example:

// packages/infra/src/orders/OrderRepositoryMongo.ts
import { UserRepositoryMongo } from '../users/UserRepositoryMongo'; // ❌ Cross-feature infra import

Fix: Feature infra can only import from:

  • @packages/core (contracts and domain objects)
  • @packages/infra/shared (base classes, utilities)
  • Same feature folder

AP-6: Wrong Error Type

Detection Pattern:

File location: packages/core/src/**/actions/*.ts
Check: NotFoundError thrown for business rule violations
Check: BusinessError thrown for missing resources
Check: InvariantError thrown outside entity

Correct Usage:

SituationCorrect Error
Resource not in DBNotFoundError
Business rule prevents actionBusinessError
Invalid state transitionInvariantError (in Entity only)

AP-7: Action Signature Violation

Detection Pattern:

File location: packages/core/src/**/actions/*.ts
User action missing ctx: function.*\(deps.*input\).*Promise (without ctx)
System action with ctx: function system.*\(deps.*ctx.*input\)

Correct Signatures:

// User action - MUST have ctx
export async function createOrder(deps, ctx, input): Promise<Order>

// System action - MUST NOT have ctx
export async function systemExpireOrders(deps, input): Promise<number>

AP-8: Shared Package Importing Internal Packages

Detection Pattern:

File location: packages/shared/**/*.ts
Search for: import.*from.*@packages/core
Search for: import.*from.*@packages/infra

Violation: packages/shared/ should only contain types shared with frontend and must not import from core or infra.

Fix: Move the code to the appropriate package, or duplicate types if truly needed in both places.


AP-9: Wiring Dependencies in Handlers

Detection Pattern:

File location: apps/client/pages/api/**/*.ts
Search for: new.*RepositoryMongo\(
Search for: new.*Mailer\(
Search for: new.*Gateway\(

Violation Example:

// apps/client/pages/api/orders/index.ts
const handler = baseApi({ auth: true })
.post(async (req, res) => {
const repository = new OrderRepositoryMongo(); // ❌ VIOLATION
const mailer = new SendGridMailer(process.env.SENDGRID_KEY!); // ❌ VIOLATION

const order = await createOrder({ repository, mailer }, req.ctx, req.body);
res.json(order);
});

Problems:

IssueImpact
Connection pool per requestDB connection exhaustion under load
No singleton guaranteesDuplicated expensive resources
Testing difficultyCan't inject fakes without module mocking
Fail on first requestBad config discovered at runtime, not startup

Fix: Wire dependencies in singleton container, inject into handlers:

// apps/client/server/dependencies.ts
const orderRepository = new OrderRepositoryMongo();
const mailer = new SendGridMailer(process.env.SENDGRID_KEY!);

export function getOrderDeps() {
return { repository: orderRepository, mailer };
}

// apps/client/pages/api/orders/index.ts
import { getOrderDeps } from '@server/dependencies';

const handler = baseApi({ auth: true })
.post(async (req, res) => {
const deps = getOrderDeps(); // ✅ Inject deps
const order = await createOrder(deps, req.ctx, req.body);
res.json(order);
});

See Entry Points - Wiring Dependencies for complete examples.


AP-11: Standalone Factory Functions in Infra

Detection: Functions like createGetXxx(Model) that return closures doing MongoDB queries.

Why it's a problem: Bypasses the Repository pattern, makes testing harder, and scatters data access logic.

Fix: Convert to a Repository class implementing a core contract.


AP-12: Cross-Feature Types in Wrong Feature's Infra

Detection: Infra module imports types from another feature's core (e.g., import { FileRepository } from '@packages/core/files' inside infra/projects/).

Why it's a problem: Creates hidden coupling between features at the infrastructure layer.

Fix: Use function deps with locally-defined types. Wire at the entry point.


AP-13: System Action Used for User-Initiated Operations

Detection: system{Action} called from user-facing entry points (API routes, queue processors handling user requests).

Why it's a problem: Skips authorization checks. Makes it unclear whether the action should enforce access control.

Fix: Convert to user action with AuthContext: (deps, ctx, input).


AP-14: Direct Cross-Feature Import in Core

Detection: import { ... } from '../../other-feature/' inside a core action.

Why it's a problem: Creates hidden coupling between features, bypasses DI.

Fix: Declare a function dep with local types; wire the source action at the entry point.


Anti-Pattern Checklist

Before committing code, verify:

  • No @packages/infra imports in packages/core/
  • No database/external service imports in packages/core/
  • Handlers only validate input and call actions
  • No await in entity methods
  • All user actions have authorization checks
  • No cross-feature repository imports in core
  • No cross-feature infra imports (only @packages/infra/shared allowed)
  • Infra placement follows contract location (see Infrastructure Placement Rule)
  • Correct error types used in correct layers
  • User actions have (deps, ctx, input) signature
  • System actions have (deps, input) signature (no ctx)
  • packages/shared/ has no internal imports
  • No dependency construction in handlers (wire in server setup)

Quick Reference

See the Folder Structure in the README for the complete directory layout.


The Mental Model

"My business logic is in packages/core/.
It talks to the outside world through interfaces.
Implementations live in packages/infra/.
I wire them together at startup in each app's entry points."

That's the whole pattern.