Fair Supply LogoFair Supply - Docs

Architecture

The platform follows Clean Architecture principles with clear layer separation and dependency rules.

Layer Overview

Layer Responsibilities

LayerLocationPurpose
Presentationapps/web/src/app/Pages, Server Actions, UI components
Applicationpackages/core/src/application/use-cases/Business logic orchestration
Infrastructurepackages/core/src/infrastructure/Data access, external services
Domainpackages/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

DecisionRationale
Neo4j Graph DatabaseSupply chain relationships are naturally graph-structured
Clean ArchitectureSeparates business logic from framework concerns
Server Components FirstReduces client bundle, improves performance
GraphQL Internal APIType-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:

  1. Authenticate
  2. Validate input (Zod)
  3. Call use case or repository
  4. Revalidate cache
  5. 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