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.

Actions

← Back to README

Actions orchestrate business operations. Two types: User actions (deps, ctx, input) and System actions (deps, input). Pattern: Load -> Authorize -> Validate -> Execute -> Persist -> Side Effects. Throw NotFoundError for missing resources, BusinessError for rule violations.


What Are Actions?

Actions are single-purpose functions that orchestrate business operations:

  • Coordinate domain objects and external systems
  • Check authorization using policies
  • Handle business validation (database lookups)
  • Manage side effects (emails, notifications)
  • Throw NotFoundError or BusinessError when operations fail

Two Types of Actions

TypeSignatureCalled FromHas Auth Check
User Action(deps, ctx, input)API handlers, CLIYes
System Action(deps, input)Workers, schedulersNo

User Actions

Triggered by authenticated users, require authorization:

export async function createOrder(
deps: CreateOrderDeps,
ctx: AuthContext, // Who is making this request?
input: CreateOrderInput
): Promise<Order>

System Actions

Triggered by automated processes, no user context:

export async function systemExpireStaleOrders(
deps: ExpireStaleOrdersDeps,
input: { olderThanDays: number }
): Promise<number>

Prefix system actions with system to distinguish them:

createOrder(deps, ctx, input) // User action
systemExpireStaleOrders(deps, input) // System action

User Action Signature

export async function actionName(
deps: ActionNameDeps, // 1. Dependencies (repositories, services)
ctx: AuthContext, // 2. Auth context (who's calling)
input: ActionNameInput // 3. Action-specific data
): Promise<ReturnType>
ParameterPurpose
depsExternal systems (repositories, mailers, gateways)
ctxAuthenticated user's identity and permissions
inputData needed to perform the action

See Authorization for AuthContext definition.


Action Pattern: Load -> Authorize -> Validate -> Execute -> Persist -> Side Effects

export async function cancelOrder(
deps: CancelOrderDeps,
ctx: AuthContext,
input: CancelOrderInput
): Promise<Order> {
// 1. LOAD - Fetch required data
const order = await deps.repository.findById(input.orderId);
if (!order) throw new NotFoundError('Order not found');

// 2. AUTHORIZE - Check permissions
if (!OrderPolicies.canCancel(ctx, order)) {
throw new BusinessError('Not authorized to cancel this order');
}

// 3. VALIDATE - Business validation (optional)
if (!order.canCancel()) {
throw new BusinessError('This order cannot be cancelled');
}

// 4. EXECUTE - State change
order.cancel(input.reason);

// 5. PERSIST - Save changes
await deps.repository.save(order);

// 6. SIDE EFFECTS - External notifications
await deps.mailer.send(input.email, 'Cancelled', `Order #${order.id}`);

return order;
}

Example: Simple Create Action

// packages/core/src/orders/actions/createOrder.ts
export interface CreateOrderDeps {
repository: OrderRepository;
mailer: Mailer;
}

export interface CreateOrderInput {
customerId: string;
email: string;
items: OrderItem[];
}

export async function createOrder(
deps: CreateOrderDeps,
ctx: AuthContext,
input: CreateOrderInput
): Promise<Order> {
if (!OrderPolicies.canCreate(ctx)) {
throw new BusinessError('Not authorized to create orders');
}

const order = new Order(crypto.randomUUID(), input.customerId, input.items);
order.submit();
await deps.repository.save(order);

await deps.mailer.send(input.email, 'Order Confirmed', `Order #${order.id} placed.`);

return order;
}

Example: Action with Cross-Feature Dependencies

For actions needing to read or write data from other features, use function dependencies (see Cross-Feature Communication):

// Define what we NEED (not imported from other features)
export interface CustomerData {
id: string;
name: string;
email: string;
isInGoodStanding: boolean;
}

export interface StockCheckResult {
productId: string;
productName: string;
available: boolean;
availableQuantity: number;
}

export interface QuestUpdateData {
status: 'stopped' | 'error';
replies: string[];
}

export interface CreateOrderDeps {
repository: OrderRepository;
mailer: Mailer;
// Read from other features
getCustomer: (customerId: string) => Promise<CustomerData | null>;
checkStock: (productId: string, quantity: number) => Promise<StockCheckResult>;
// Write to other features - equally valid!
updateQuest: (questId: string, data: QuestUpdateData) => Promise<void>;
}

export async function createOrder(
deps: CreateOrderDeps,
ctx: AuthContext,
input: CreateOrderInput
): Promise<Order> {
if (!OrderPolicies.canCreate(ctx)) {
throw new BusinessError('Not authorized to create orders');
}

// Business validation via function dependencies
const customer = await deps.getCustomer(input.customerId);
if (!customer) throw new NotFoundError('Customer not found');
if (!customer.isInGoodStanding) {
throw new BusinessError('Customer account is not in good standing');
}

for (const item of input.items) {
const stock = await deps.checkStock(item.productId, item.quantity);
if (!stock.available) {
throw new BusinessError(`Insufficient stock for ${stock.productName}`);
}
}

const order = new Order(crypto.randomUUID(), input.customerId, input.items);
order.submit();
await deps.repository.save(order);

await deps.mailer.send(customer.email, 'Order Confirmed', `Order #${order.id} placed.`);

return order;
}

Example: System Action

// packages/core/src/orders/actions/systemExpireStaleOrders.ts
export interface SystemExpireStaleOrdersDeps {
repository: OrderRepository;
mailer: Mailer;
}

export async function systemExpireStaleOrders(
deps: SystemExpireStaleOrdersDeps,
input: { olderThanDays: number }
): Promise<number> {
const staleOrders = await deps.repository.findStale(input.olderThanDays);

for (const order of staleOrders) {
order.cancel('Expired - no payment received');
await deps.repository.save(order);
await deps.mailer.send(order.customerEmail, 'Order Expired', `Order #${order.id} expired.`);
}

return staleOrders.length;
}

Called from a worker:

// apps/worker/src/jobs/expireOrders.ts
export async function expireOrdersJob(deps: OrderDeps) {
const count = await systemExpireStaleOrders(deps, { olderThanDays: 7 });
console.log(`[Worker] Expired ${count} stale orders`);
}

What Goes in Actions vs Entities

In ActionsIn Entities
Authorization (policies)State validation
Loading from databaseState transitions
Saving to databaseComputed properties
Sending emailsBusiness invariants
External API callscanDoX() checks
Logging / analyticsCore calculations

Complex Actions: Step Functions

When actions become complex, break into discrete, testable steps:

// Step functions (private, testable)
async function loadAndValidateCustomer(deps, customerId: string): Promise<CustomerData> {
const customer = await deps.getCustomer(customerId);
if (!customer) throw new NotFoundError('Customer not found');
if (!customer.isInGoodStanding) throw new BusinessError('Customer not in good standing');
return customer;
}

async function validateStock(deps, items: OrderItem[]): Promise<void> {
for (const item of items) {
const stock = await deps.checkStock(item.productId, item.quantity);
if (!stock.available) throw new BusinessError(`Insufficient stock for ${stock.productName}`);
}
}

async function handlePayment(deps, customerId: string, amount: number): Promise<string> {
const result = await deps.processPayment(customerId, amount);
if (!result.success) throw new BusinessError(`Payment failed: ${result.error}`);
return result.transactionId!;
}

// Main action orchestrates steps
export async function createOrderWithPayment(
deps: CreateOrderWithPaymentDeps,
ctx: AuthContext,
input: CreateOrderInput
): Promise<Order> {
if (!OrderPolicies.canCreate(ctx)) throw new BusinessError('Not authorized');

const customer = await loadAndValidateCustomer(deps, input.customerId);
await validateStock(deps, input.items);

const order = new Order(crypto.randomUUID(), input.customerId, input.items);
order.submit();

const transactionId = await handlePayment(deps, customer.id, order.total);
order.markPaid(transactionId);

await deps.repository.save(order);

// Non-critical side effects - don't fail the order
await sendNotifications(deps, order, customer).catch(console.error);

return order;
}
SituationUse Step Functions?
Simple CRUD (3-4 operations)No
5+ distinct operationsYes
Multiple validation checksYes
Steps need independent testingYes
Same steps in multiple actionsYes, extract and reuse

Organizing Actions

Keep actions in a flat folder structure:

packages/core/src/orders/
|- Order.ts
|- OrderRepository.ts
|- actions/
| |- createOrder.ts
| |- cancelOrder.ts
| +- shipOrder.ts
+- index.ts

Exporting Actions

// packages/core/src/orders/index.ts
export { Order, OrderItem, OrderStatus } from './Order';
export { OrderRepository } from './OrderRepository';
export { createOrder, CreateOrderInput, CreateOrderDeps } from './actions/createOrder';
export { cancelOrder, CancelOrderInput, CancelOrderDeps } from './actions/cancelOrder';

When to Split Features

If a feature has 15+ actions or actions deal with distinct sub-entities, consider splitting. See Feature Design for guidance.


Dependencies Interface

For cross-feature data, use function dependencies. These work for both reading and writing:

// Basic - single feature only
export interface CreateOrderDeps {
repository: OrderRepository;
mailer: Mailer;
}

// With cross-feature 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<StockCheckResult>;
// Write to other features - equally valid!
updateQuest: (questId: string, data: QuestUpdateData) => Promise<void>;
}

Input Interface

Always use an object, even for single values:

export interface CreateOrderInput {
customerId: string;
email: string;
items: OrderItem[];
}

// Even for simple actions
export interface GetOrderInput {
orderId: string;
}

System vs User Actions

CriteriaUser ActionSystem Action
CallerAPI route, queue handler processing a user requestCron job, system maintenance, internal automation
AuthContextRequired — (deps, ctx, input)Not present — (deps, input)
AuthorizationAction checks user accessNo user to authorize
Naming{verbNoun}.tssystem{VerbNoun}.ts
ExamplegetProject, getUsersystemExpireOrders, systemCleanupStaleData

Rule of thumb: If there's a user behind the request (even indirectly through a queue), it's a user action. System actions are for truly autonomous operations.


Cross-Feature Action Reuse

When a core action needs data/behavior from another domain, it does NOT import directly. Instead:

  1. Check if the source domain already has an action that provides what you need
  2. If not, create the action in the source domain — the action belongs where the data lives
  3. Declare a function dep with locally-defined types in the consuming action — no imports from the source domain
  4. Wire at the entry point — the entry point calls the source domain's action and passes the result through the function dep

Example — getProjectSystemPrompts needs file contents:

core/projects/getProjectSystemPrompts.ts
└─ declares dep: getFileContents: (fileIds, userId) => Promise<FileContentItem[]>
└─ FileContentItem defined locally (no import from core/files)

core/files/getFileContents.ts
└─ already exists, returns FileWithContent[] (structurally compatible)

Entry point (questProcessor.ts)
└─ wires: getFileContents action → getProjectSystemPrompts.getFileContents dep

Anti-pattern: Importing getFileContents directly inside getProjectSystemPrompts — this creates a hidden cross-feature coupling that bypasses the dependency injection pattern.


Next Steps