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.

The Shared Package

← Back to README


Why a Shared Package?

When you have multiple apps (server, web, mobile), they often need the same types:

THE PROBLEM THE SOLUTION

apps/client/server/ apps/client/src/ packages/shared/
OrderDTO { ... } OrderDTO { ... } OrderDTO { ... } <- Single source
Types get out of sync! All apps import from shared

What Belongs in Shared

Put in shared/Keep in core/
API request/response typesEntities (write models)
Validation schemas (Zod)Business logic
Enums and constantsContracts (interfaces)
Error codesActions and queries
Common utilitiesDomain rules

Rule: If a frontend needs it, put it in shared/. If it's internal business logic, keep it in core/.


Folder Structure

packages/
|- core/ # Business logic (backend only)
| +- src/orders/
| |- Order.ts # Entity (internal)
| +- OrderRepository.ts
|
|- infra/ # Infrastructure (backend only)
|
+- shared/ # Shared across all apps
+- src/
|- api-types/ # Request/Response DTOs
| |- orders.ts
| +- index.ts
|
|- validation/ # Zod schemas
| |- orders.ts
| +- index.ts
|
+- constants/ # Shared constants
+- orderStatuses.ts

API Types

Define what your API receives and returns:

// packages/shared/src/api-types/orders.ts

export type OrderStatus = 'draft' | 'submitted' | 'paid' | 'shipped' | 'cancelled';

export interface OrderSummary {
id: string;
customerName: string;
total: number;
status: OrderStatus;
createdAt: string; // ISO string for JSON
}

export interface OrderDetails {
id: string;
customer: { id: string; name: string; email: string };
items: OrderLineItem[];
total: number;
status: OrderStatus;
canCancel: boolean;
}

export interface CreateOrderRequest {
customerId: string;
items: Array<{ productId: string; quantity: number; price: number }>;
}

export interface ApiResponse<T> { data: T; }
export interface ApiError { error: string; code?: string; }

Shared Validation (Zod)

Use Zod schemas for validation on both frontend and backend:

// packages/shared/src/validation/orders.ts
import { z } from 'zod';

export const createOrderSchema = z.object({
customerId: z.string().uuid('Invalid customer ID'),
items: z.array(z.object({
productId: z.string().uuid('Invalid product ID'),
quantity: z.number().int().min(1, 'Quantity must be at least 1'),
price: z.number().positive('Price must be positive'),
})).min(1, 'Order must have at least one item'),
});

export type CreateOrderRequest = z.infer<typeof createOrderSchema>;

Backend usage:

const parsed = createOrderSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Validation failed' });
const order = await createOrder(deps, req.ctx, parsed.data);

Frontend usage:

const result = createOrderSchema.safeParse(formData);
if (!result.success) setErrors(result.error.flatten().fieldErrors);

Shared Constants

// packages/shared/src/constants/orderStatuses.ts
export const ORDER_STATUS_LABELS: Record<string, string> = {
draft: 'Draft',
submitted: 'Pending Payment',
paid: 'Paid',
shipped: 'Shipped',
cancelled: 'Cancelled',
};

export const CANCELLABLE_STATUSES = ['draft', 'submitted', 'paid'] as const;

Import Rules

See Rules & Guidelines for complete import rules.

PackageCan Import From
packages/sharedExternal libs only (zod, etc.)
packages/core@packages/shared
packages/infra@packages/core, @packages/shared
apps/client/serverEverything
apps/client/src@packages/shared only

Key rule: shared has no internal dependencies.


Usage Examples

Backend Handler

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

const handler = baseApi({ auth: true })
.post(async (req, res) => {
const parsed = createOrderSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Validation failed' });

const order = await createOrder(deps, req.ctx, parsed.data);
const response: ApiResponse<OrderDetails> = { data: order };
res.status(201).json(response);
});

export default handler;

Frontend API Client

// apps/client/src/api/orders.ts
import { OrderDetails, CreateOrderRequest, ApiResponse } from '@packages/shared/api-types';

export async function createOrder(data: CreateOrderRequest): Promise<OrderDetails> {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const json: ApiResponse<OrderDetails> = await res.json();
return json.data;
}

When NOT to Use Shared

Keep Out of SharedWhy
EntitiesInternal to backend
Business logicBackend only
Database contractsBackend only
React componentsFrontend only
Next.js middlewareBackend only

If only one app uses it, don't share it.


Relationship to Core Read Models

WhereWhen
core/{feature}/OrderReadModels.tsBackend-only queries
shared/api-types/orders.tsExposed via API, consumed by frontend

In practice, they're often the same. You can define in core and re-export from shared if needed.


Next Steps