Skip to content

Common Patterns and Technology Picks

Common Patterns and Technology Picks

This section maps FCA’s abstract concepts to concrete implementation patterns, technology choices, and decision criteria. Each pattern is shown with when to use it, when to avoid it, and how it manifests at different levels.

Port Patterns

The Provider Interface

The most common port pattern. Define an interface in the component that needs the dependency. Implement it elsewhere.

When: The component depends on an external system (database, API, filesystem, clock, randomness) and needs to be verifiable in isolation.

When not: The dependency is a pure library with no I/O (e.g., a math library, a string formatter). Those are direct imports, not ports.

// Port definition — lives in @taskflow/core
interface DatabasePort {
query<T>(sql: string, params: unknown[]): Promise<T[]>;
execute(sql: string, params: unknown[]): Promise<{ rowCount: number }>;
transaction<T>(fn: (tx: DatabasePort) => Promise<T>): Promise<T>;
}
// Production — lives in @taskflow/api
class PostgresDatabase implements DatabasePort { ... }
// Verification — lives in @taskflow/testkit
class InMemoryDatabase implements DatabasePort {
private tables = new Map<string, unknown[]>();
queries: Array<{ sql: string; params: unknown[] }> = []; // Recording for assertions
// ...20 lines total
}

Technology picks by ecosystem:

EcosystemPort mechanismExample
TypeScriptinterface + constructor injectionclass Service { constructor(private db: DatabasePort) {} }
TypeScript + EffectContext.Tag + Layerconst Database = Context.Tag<DatabasePort>()
Scala + Cats EffectTagless final (F[_]: Monad)class Service[F[_]: Monad](db: DatabasePort[F])
RustTrait + generic parameterstruct Service<D: DatabasePort> { db: D }
GoInterface + struct fieldtype Service struct { db DatabasePort }

The Effect Service Layer

When ports need lifecycle management (connection pools, cleanup, startup) and composable error handling, use an effect system to formalize the port as a service layer.

When: The port has lifecycle concerns (must be opened/closed), can fail in structured ways, or needs to participate in resource management.

When not: The port is stateless and infallible (e.g., a clock, a UUID generator). A plain interface is simpler.

// Effect-based port — the Requirements type parameter IS the port
import { Effect, Context, Layer } from 'effect';
class Database extends Context.Tag('Database')<Database, {
query: <T>(sql: string, params: unknown[]) => Effect.Effect<T[], DatabaseError>;
execute: (sql: string, params: unknown[]) => Effect.Effect<number, DatabaseError>;
}>() {}
// Usage in domain logic — declares the port in the type signature
const createTask = (input: CreateTaskInput): Effect.Effect<Task, TaskError, Database> =>
Effect.gen(function* () {
const db = yield* Database;
const task = buildTask(input);
yield* db.execute('INSERT INTO tasks ...', [task.id, task.title]);
return task;
});
// Production Layer — provides the port implementation
const PostgresLayer = Layer.succeed(Database, {
query: (sql, params) => Effect.tryPromise(() => pool.query(sql, params)),
execute: (sql, params) => Effect.tryPromise(() => pool.query(sql, params).then(r => r.rowCount)),
});
// Test Layer — provides the mock
const TestLayer = Layer.succeed(Database, {
query: () => Effect.succeed([]),
execute: () => Effect.succeed(1),
});

Technology picks:

NeedTechnologyWhy
TypeScript with structured concurrencyEffectEffect<A, E, R> encodes interface (A), error domain (E), and ports (R) in the type
Scala with structured concurrencyCats Effect + Tagless FinalF[_] abstracts over the effect type, resources manage lifecycle
Rust with structured concurrencyTower service traitService<Request> with Future return type
Simple TypeScript, no effect systemPlain interfaces + async/awaitinterface DatabasePort { query(...): Promise<T> }

Decision criterion: If your L0 functions need to express “I require these capabilities” in their type signature, use an effect system. If port injection only happens at L2-L3 (service construction), plain interfaces suffice.

Verification Patterns

Unit Verification (L0-L2)

Test pure logic by calling it with constructed inputs. No mocks, no infrastructure.

When: Always, for every pure function and module.

// L0 — call the function
assert.deepEqual(
transitionTaskState(
{ id: '1', state: 'todo', assignee: null },
{ type: 'assign', userId: 'user-1' }
),
{ id: '1', state: 'todo', assignee: 'user-1' }
);

Builder Pattern (L2-L3)

Construct complex domain objects with sensible defaults. Tests override only what they care about.

When: Domain objects have many fields and tests only care about a few. Without builders, every test file constructs objects with 15 fields — most irrelevant to the assertion.

// In testkit — fluent builder
const task = taskBuilder()
.withState('in_progress')
.withAssignee('user-123')
.build();
// The builder provides sensible defaults for id, title, createdAt, priority, etc.
// The test only specifies what it's actually testing.

Technology picks:

ApproachWhenExample
Fluent builder (method chaining)Complex objects with many optional fieldstaskBuilder().withState('done').build()
Factory function with partial overridesSimpler objects, fewer variationsmakeTask({ state: 'done' })
Faker/fixture libraryNeed realistic random datataskFactory.create({ state: 'done' })

Recording Provider (L2-L3)

A test double that implements a port interface and records all interactions for later assertion.

When: You need to verify that a component called the right port methods with the right arguments, without running the actual external system.

const recorder = new RecordingNotificationPort();
const service = new TaskService(db, search, recorder);
await service.completeTask('task-1');
assert.equal(recorder.calls.length, 1);
assert.equal(recorder.calls[0].method, 'send');
assert.deepEqual(recorder.calls[0].args.recipient, 'assignee@example.com');

Contract Verification (L3-L4)

Verify that a port implementation actually satisfies the port interface’s behavioral contract — not just the type signature.

When: You have multiple port implementations (Postgres in production, SQLite in CI, InMemory in unit tests) and need to ensure they all behave identically.

// Shared contract test — runs against any DatabasePort implementation
function databaseContractSuite(createDb: () => Promise<DatabasePort>) {
it('returns empty array for no results', async () => {
const db = await createDb();
const results = await db.query('SELECT * FROM tasks WHERE id = $1', ['nonexistent']);
assert.deepEqual(results, []);
});
it('execute returns affected row count', async () => {
const db = await createDb();
const result = await db.execute('INSERT INTO tasks (id, title) VALUES ($1, $2)', ['1', 'Test']);
assert.equal(result.rowCount, 1);
});
}
// Run the same contract against all implementations
describe('PostgresDatabase', () => databaseContractSuite(() => createPostgresDb()));
describe('InMemoryDatabase', () => databaseContractSuite(() => createInMemoryDb()));
describe('SQLiteDatabase', () => databaseContractSuite(() => createSQLiteDb()));

Architecture Gate Testing

Structural Fitness Functions (L2-L4)

Tests that verify the architecture itself — not behavior, but structural invariants. These are automated guards that prevent FCA violations from being introduced. They run on every test suite execution with zero extra tooling.

When: The codebase has structural rules (port discipline, boundary enforcement, layer ordering) that TypeScript’s type system alone cannot enforce. Import violations are silent — they compile fine but degrade the architecture. Gate tests catch them the same way behavioral tests catch logic bugs.

Why this matters: Code reviews catch violations reactively, after the code is written. Gate tests catch them proactively, before the code is merged. A single architecture.test.ts file replaces an entire class of review comments.

Pattern: Scan source files, extract import statements, and assert structural rules:

// architecture.test.ts — co-located at the component root
import { readdirSync, readFileSync } from 'node:fs';
// Collect all production .ts files (exclude *.test.ts)
const domainFiles = collectTsFiles(DOMAINS_DIR).filter(f => !isTestFile(f));
describe('G-PORT: Domain code uses ports, not direct imports', () => {
it('no direct fs or js-yaml in domain production code', () => {
const violations = [];
for (const file of domainFiles) {
for (const imp of extractImports(file)) {
if (/^(node:)?fs/.test(imp.specifier)) {
violations.push(`${file}:${imp.line} — direct fs import`);
}
}
}
assert.deepStrictEqual(violations, []);
});
});
describe('G-BOUNDARY: No cross-domain runtime imports', () => {
it('domains only import from ports/ and shared/, not siblings', () => {
// Skip type-only imports (erased at compile time)
// Flag runtime imports between sibling domains
});
});
describe('G-LAYER: Package layer ordering respected', () => {
it('L1 core does not import L2+ packages', () => {
// Scan core/src/ for imports of @method/methodts, @method/mcp, @method/bridge
});
});

Three standard gates:

GateFCA PrincipleWhat it checks
G-PORTP3 (Port pattern)Domain production code has no direct imports of external I/O modules (fs, js-yaml, child_process). Must go through port interfaces.
G-BOUNDARYP7 (Boundaries)Sibling domains do not import each other’s internals at runtime. import type is allowed (erased at compile time). Imports from ports/ and shared/ are allowed (cross-cutting infrastructure).
G-LAYERP5 (Pure composition)Lower-layer packages never import higher-layer packages. L0→L1→L2→L3→L4 dependency direction is enforced.

Known exceptions: Some violations are intentional (e.g., trigger watchers using native fs.watch(), scope hooks generating git pre-commit hooks). Document these as a Set<string> in the test file with a comment explaining why each exception exists. The exceptions list IS the architecture debt tracker — grep for it to find what still needs fixing.

/** Files that may use direct fs — infrastructure-boundary code. */
const FS_EXCEPTIONS = new Set([
'triggers/file-watch-trigger.ts', // native fs.watch() — fundamentally different from read/write
'sessions/scope-hook.ts', // generates git hooks — platform-coupled infrastructure
]);

Technology picks:

ApproachWhenExample
Grep-based test (zero deps)Any TypeScript projectScan files, extract imports, assert rules
dependency-cruiserComplex dependency rules.dependency-cruiser.cjs with declarative rules
ESLint no-restricted-pathsIDE-time enforcementFlag violations as you type, not at test time
Custom tsc pluginCompile-time enforcementBlock compilation on structural violations

Recommendation: Start with the grep-based test. It runs in < 1 second, requires zero dependencies, and catches 95% of violations. Add dependency-cruiser or ESLint rules later if the exception list grows beyond ~10 entries or if you need more granular rules (e.g., “domain A may import domain B’s types but not domain C’s”).

Placement: Co-locate the gate test at the component level it guards. For a monorepo bridge package with domain-co-located structure, place it at packages/bridge/src/shared/architecture.test.ts so it runs with the standard test suite.

Observability Patterns

Metric Definitions (L1-L2)

Define what a module measures in a co-located *.metrics.ts file. The definitions are declarative — they describe the metric, not the infrastructure.

When: The module performs operations that have meaningful rates, durations, or counts.

// task-transitions.metrics.ts — co-located with task-transitions.ts
import { counter, histogram } from '@observability/definitions';
export const taskTransitionCount = counter({
name: 'task_transitions_total',
description: 'Number of task state transitions',
labels: ['from_state', 'to_state', 'trigger'],
});
export const taskTransitionDuration = histogram({
name: 'task_transition_duration_ms',
description: 'Duration of task state transition processing',
buckets: [1, 5, 10, 25, 50, 100],
});

Build tools extract these definitions and generate infrastructure-specific outputs (Prometheus metrics, Grafana dashboards, DataDog monitors).

Domain Events (L2-L3)

Emit semantic events that describe what happened in the domain — not what functions were called.

When: The domain has state transitions, decisions, or lifecycle events that operators, auditors, or other components need to observe.

// Domain event — semantic, not infrastructural
interface TaskEvent {
type: 'task.created' | 'task.transitioned' | 'task.completed' | 'task.escalated';
taskId: string;
timestamp: string;
actor: string;
metadata: Record<string, unknown>;
}
// Emitted by domain logic, consumed by observability infrastructure
eventBus.emit({
type: 'task.transitioned',
taskId: task.id,
timestamp: new Date().toISOString(),
actor: 'user-123',
metadata: { from: 'in_progress', to: 'done', duration_ms: 3600000 },
});

Technology picks:

LevelPatternTechnology examples
L0-L1Function traces / spansOpenTelemetry SDK, Effect tracing, console.time
L2Domain event busEventEmitter, Effect PubSub, RxJS Subject
L3Exported event streamNode.js EventEmitter export, async iterator, channel system
L4Structured logging + metrics + tracesPino/Winston + Prometheus + OpenTelemetry + Grafana
L5Distributed tracing + SLO dashboardsJaeger/Tempo + Grafana SLO + PagerDuty

Health and Readiness (L4)

Every service exposes health and readiness endpoints. Health indicates the process is running. Readiness indicates it can serve requests (database connected, caches warm, etc.).

When: Always, for every L4 service.

// Health — is the process alive?
app.get('/health', () => ({ status: 'ok', uptime_ms: process.uptime() * 1000 }));
// Readiness — can it serve requests?
app.get('/ready', async () => {
const dbOk = await db.query('SELECT 1').then(() => true).catch(() => false);
const searchOk = await search.ping().then(() => true).catch(() => false);
const allReady = dbOk && searchOk;
return { status: allReady ? 'ready' : 'degraded', checks: { database: dbOk, search: searchOk } };
});

Configuration Patterns

Schema-First Configuration (L2-L3)

Define configuration as a typed schema with defaults and validation. The schema is the documentation.

When: The component has configurable behavior (timeouts, limits, feature flags, connection strings).

// task-service.config.ts — co-located with the service
import { z } from 'zod';
export const TaskServiceConfig = z.object({
maxTasksPerProject: z.number().default(10_000),
defaultPriority: z.enum(['low', 'medium', 'high']).default('medium'),
staleDuration: z.number().default(7 * 24 * 60 * 60 * 1000).describe('Milliseconds before a task is marked stale'),
enableAutoAssignment: z.boolean().default(false),
});
export type TaskServiceConfig = z.infer<typeof TaskServiceConfig>;

Technology picks:

ApproachWhenExample
Zod schemaTypeScript, runtime validation neededz.object({ port: z.number().default(3000) })
io-tsTypeScript + fp-ts ecosystemt.type({ port: t.number })
Environment variables + schemaL4 services, 12-factor appTaskServiceConfig.parse(process.env)
Config files (YAML/JSON) + schemaComplex nested configurationLoad file, validate against Zod schema

The rule: configuration schemas live next to the code they configure. task-service.config.ts lives alongside task-service.ts. Build tools extract schemas for documentation and validation.

Documentation Patterns

README as Index

Every directory with multiple files gets a README that indexes its contents. The README is the table of contents — it lists children with one-line summaries and links.

When: Always, for every directory with more than one file.

---
title: Task Domain
scope: domain
package: core
contents:
- task-transitions.ts
- task-validation.ts
- task-queries.ts
---
# Task Domain
State machine, validation rules, and query builders for the task lifecycle.
| Module | Purpose |
|--------|---------|
| [task-transitions](task-transitions.ts) | Pure state machine: `(Task, Event) → Task` |
| [task-validation](task-validation.ts) | Input validation and business rules |
| [task-queries](task-queries.ts) | Type-safe query builders for task retrieval |

JSDoc for Interface, Comments for Why

Interface-level (L0-L1): Every exported function, type, and interface gets JSDoc describing what it does and when to use it.

Implementation-level: Comments only where the why is non-obvious. Never comment what — the code already says what.

/**
* Transition a task's state in response to a workflow event.
*
* Returns a new Task with the updated state. Does not persist —
* the caller decides whether to commit the transition.
*/
export function transitionTaskState(task: Task, event: TaskEvent): Task {
// Validate before transitioning — invalid transitions return the task unchanged
// rather than throwing, because batch processors need to continue on failure.
if (!canTransition(task.state, event.type)) return task;
return { ...task, state: nextState(task.state, event.type) };
}

Decision Records

When a non-obvious architectural choice is made, document the alternatives considered, the decision, and the rationale. Named NNN-descriptive-title.md in a decisions/ directory.

When: You chose between two reasonable alternatives and someone will later ask “why didn’t we do X instead?”

---
title: PostgreSQL over MongoDB for task storage
status: accepted
date: 2026-03-15
---
# 001 — PostgreSQL over MongoDB for Task Storage
# Context
Tasks have relational structure (projects → tasks → comments → attachments).
Workflow transitions are transactional (move task + update counters + log event).
# Decision
Use PostgreSQL with JSONB columns for flexible metadata.
# Alternatives Considered
- **MongoDB**: Better for unstructured data, but task relationships are relational.
Transactions across collections are complex and slower.
- **SQLite**: Simpler, but doesn't support concurrent writers for multi-worker deployment.
# Consequences
- Need schema migrations for structural changes.
- JSONB gives flexibility for custom fields without schema changes.
- Transactions are straightforward with `BEGIN/COMMIT`.

Frontend Patterns

Frontend code presents a unique FCA question: it shares domain knowledge with the backend (types, validation, constants) but runs in a different runtime (browser vs server) with different constraints (no filesystem, no database, different security model). Three patterns exist, each appropriate at different scales and with different trade-offs.

Pattern A: Shared Types, Separate Frontend Package

The frontend is its own L3 component. It imports shared types and validation schemas from a lower-layer types package but defines its own UI components, API clients, and state management. Frontend and backend are separate packages with separate builds.

When: Most projects. The frontend has its own deployment (CDN, static hosting), its own build tooling (Vite, webpack), and its own framework (React, Vue, Svelte). The runtime boundary between browser and Node is real and significant.

packages/
types/ # Shared: types, Zod schemas, domain constants
core/ # Server: pure domain logic
api/ # Server: HTTP routes + port implementations
frontend/ # Client: React SPA, imports from @taskflow/types only
source/
tasks/ # Mirrors backend task domain
task-list.tsx
task-api.ts # HTTP client for /api/tasks
workflows/ # Mirrors backend workflow domain
workflow-editor.tsx
workflow-api.ts

Key rule: the frontend mirrors the backend domain structure internally. It organizes by domain (tasks/, workflows/), not by artifact type (components/, hooks/, pages/). Each frontend domain is an L2 component whose vocabulary matches the corresponding backend domain.

Co-location achieved through: shared types package. Validation schemas, enums, and constants are written once and imported by both runtimes.

// In @taskflow/types — shared between server and client
export const CreateTaskSchema = z.object({
title: z.string().min(1).max(200),
priority: z.enum(['low', 'medium', 'high']),
projectId: z.string().uuid(),
});
// Server uses it for request validation
app.post('/tasks', (req) => { const input = CreateTaskSchema.parse(req.body); ... });
// Client uses it for form validation
const form = useForm({ resolver: zodResolver(CreateTaskSchema) });

Pattern B: Domain-Co-located UI Artifacts

UI components live inside the backend domain directory alongside server code. Build tools separate for deployment — the server build strips *.ui.tsx, the client build strips server-only *.ts.

When: Full-stack TypeScript teams where the same developer works on both server and client for a domain. Frameworks like Remix or Next.js app router encourage this pattern natively. Requires build tooling that can split by file convention.

packages/core/source/tasks/
task-transitions.ts # Server: pure state machine
task-transitions.test.ts # Server: unit verification
task-transitions.schema.ts # Shared: Zod validation (both runtimes)
task-transitions.ui.tsx # Client: React component showing transitions
task-transitions.ui.test.tsx # Client: component test
README.md

Build separation:

  • build:server → includes *.ts, excludes *.ui.tsx
  • build:client → includes *.ui.tsx + *.schema.ts, excludes server-only code
  • build:test → includes everything

Co-location achieved through: file naming conventions. All artifacts of the task-transitions concept — server logic, client UI, shared validation, tests for both — live in one directory. A developer changing the state machine immediately sees the UI that renders it.

Trade-off: Requires custom build tooling. Not standard in the TypeScript ecosystem today (unlike Rust’s #[cfg(test)] which is built into the compiler).

Pattern C: Framework-Mediated Full-Stack

The framework itself manages the server/client split within a single file or route module. Server functions and client components coexist in the same module, and the framework’s compiler separates them.

When: Using Remix, Next.js app router, SolidStart, or similar full-stack frameworks that provide built-in server/client boundary management.

// Remix route module — server and client in one file
// The framework strips server code from the client bundle automatically
export async function loader({ params }: LoaderArgs) {
// Runs on server only
const task = await db.query('SELECT * FROM tasks WHERE id = $1', [params.id]);
return json(task);
}
export default function TaskDetail() {
// Runs on client only
const task = useLoaderData<typeof loader>();
return <div>{task.title} — {task.state}</div>;
}

Co-location achieved through: the framework. No custom build tooling needed — the framework’s compiler knows which code runs where.

Trade-off: Couples to a specific framework. The domain logic is not independently composable outside the framework context.

Choosing a frontend pattern

FactorPattern A (Separate Package)Pattern B (Co-located UI)Pattern C (Framework Full-Stack)
Team structureSeparate frontend/backend teamsFull-stack developersFull-stack developers
Build complexityStandard (Vite + tsc)Custom plugins neededFramework-provided
Domain coherenceShared types, mirrored structureMaximum — one directory per conceptMaximum — one file per route
Runtime safetyStrong — separate packages can’t accidentally shareRequires discipline — build tool enforcesStrong — framework enforces
Framework couplingNoneNoneHigh (Remix, Next.js, etc.)
Reuse across clientsEasy — multiple frontends import same typesHarder — UI tied to domain directoryHardest — tied to framework
Recommended forMost projects, API-first designMonorepo full-stack TypeScriptFramework-native full-stack apps

The patterns are not mutually exclusive. A project can use Pattern A at L3 (separate frontend package) while using Pattern B at L2 (co-located *.schema.ts files in domain directories). The shared types package from Pattern A is valuable regardless of which UI pattern is chosen.

Choosing Technology by Level

ConcernL0-L2 (Domain)L3 (Package)L4 (Service)
LanguageTypeScript strict, pure functionsTypeScript strictTypeScript, framework-specific
Effect systemEffect library (optional but recommended)Effect for port-heavy packagesPlain async/await for route handlers
ValidationAlgebraic data types, branded typesZod schemas at package boundariesZod for HTTP request parsing
VerificationNode test runner, co-located *.test.tsTestkit package (builders + assertions)Contract tests, integration tests
Observability*.metrics.ts definitionsExported event streamsOpenTelemetry + Prometheus + Grafana
ConfigurationHardcoded defaults, minimal configZod schema per domainEnvironment variables + Zod schema
DocumentationJSDoc + README per directorydocumentation/ with guides + decisionsAPI docs (OpenAPI), deployment guides
Error handlingReturn types (Result<T, E>, Effect<A, E, R>)Typed error hierarchiesHTTP status codes + error response schemas