Architecture
The platform follows Clean Architecture principles with clear layer separation and dependency rules.
Layer Overview
Layer Responsibilities
| Layer | Location | Purpose |
|---|---|---|
| Presentation | apps/web/src/app/ | Pages, Server Actions, UI components |
| Application | packages/core/src/application/use-cases/ | Business logic orchestration |
| Infrastructure | packages/core/src/infrastructure/ | Data access, external services |
| Domain | packages/core/src/domain/ | Errors, events, shared types |
Dependency Flow
Dependencies flow inward only:
Presentation → Application → Infrastructure → Domain- Presentation can import from Application, Infrastructure, Domain
- Application can import from Infrastructure, Domain
- Infrastructure can import from Domain
- Domain has no internal dependencies
Key Architectural Decisions
| Decision | Rationale |
|---|---|
| Neo4j Graph Database | Supply chain relationships are naturally graph-structured |
| Clean Architecture | Separates business logic from framework concerns |
| Server Components First | Reduces client bundle, improves performance |
| GraphQL Internal API | Type-safe data access with generated types |
Use Case vs Repository
When to use each pattern:
Use Case
Create a Use Case when you need:
- Business validation beyond Zod schema
- Data transformation rules
- User context required (scoped to account)
- Multiple repository calls
- Logic reused across pages/jobs
// packages/core/src/application/use-cases/organisation/create-transaction.ts
export class CreateTransaction implements UseCase<Input, Output> {
async execute(input: Input): Promise<Output> {
// 1. Business validation
if (input.amount <= 0) {
throw new ValidationError('Amount must be positive');
}
// 2. Multiple repository calls
const organisation = await OrganisationRepository.findById(input.orgId);
if (!organisation) throw new NotFoundError('Organisation');
// 3. Create with business logic
const transaction = await TransactionRepository.create({
...input,
amountUsd: convertToUsd(input.amount, input.currency),
});
return transaction;
}
}Repository
Use Repository directly when:
- Simple CRUD with no business logic
- Zod validation is sufficient
- One-off operation
// Simple fetch - use repository directly
const organisation = await OrganisationRepository.findById(id);
// Simple search - use repository directly
const results = await OrganisationRepository.search({ query: 'Acme' });Server Actions
Server Actions are thin wrappers that:
- Authenticate
- Validate input (Zod)
- Call use case or repository
- Revalidate cache
- Return result
// apps/web/src/app/transactions/actions.ts
'use server';
import { createServerAction } from 'zsa';
export const createTransactionAction = createServerAction()
.input(z.object({ ... }))
.handler(async ({ input }) => {
const userId = await requireAuth();
const useCase = new CreateTransaction();
const result = await useCase.execute({ userId, ...input });
revalidatePath('/transactions');
return { success: true, data: result };
});File Structure
packages/core/src/
├── application/
│ └── use-cases/
│ ├── account/
│ │ ├── create-account.ts
│ │ └── add-user-to-account.ts
│ ├── organisation/
│ │ ├── create-transaction.ts
│ │ └── promote-provisional.ts
│ └── engagement/
│ └── create-engagement.ts
├── infrastructure/
│ ├── repositories/
│ │ ├── account-repository.ts
│ │ ├── organisation-repository.ts
│ │ └── transaction-repository.ts
│ ├── graphql/
│ │ └── execute-graphql.ts
│ ├── neo4j/
│ │ └── schemas/
│ ├── redis/
│ ├── auth0/
│ └── clickhouse/
└── domain/
├── errors/
│ ├── not-found-error.ts
│ └── validation-error.ts
└── events/Key Patterns
Repository Pattern
Repositories are static classes that handle data access:
export class OrganisationRepository {
static async findById(id: string) {
const result = await executeGraphQL(FindOrganisationDocument, {
where: { id: { eq: id } },
});
return result.organisations?.[0] ?? null; // Return null, not error
}
static async create(input: CreateOrganisationInput) {
const result = await executeGraphQL(CreateOrganisationDocument, {
input,
});
return result.createOrganisations.organisations[0];
}
}Domain Errors
Use domain errors for business rule violations:
// Not found - repository returns null, use case throws
const org = await OrganisationRepository.findById(id);
if (!org) throw new NotFoundError('Organisation');
// Validation - use case throws
if (input.amount <= 0) {
throw new ValidationError('Amount must be positive');
}GraphQL Type Pass-Through
Don't create manual type mappings. Use generated GraphQL types directly:
// Good - use generated types
import type { Organisation } from '@repo/core/graphql';
// Bad - manual type mapping
interface OrganisationDTO {
id: string;
name: string;
}Next Steps
- Domain Model - How domains connect
- GraphQL - Data access patterns
- Component Patterns - UI architecture