11 KiB
Clean Architecture plan for GridPilot (NestJS‑focused)
This document defines the target architecture and rules for GridPilot. It is written as a developer-facing contract: what goes where, what is allowed, and what is forbidden.
1. Architectural goals
- Strict Clean Architecture (dependency rule enforced)
- Domain-first design (DDD-inspired)
- Frameworks are delivery mechanisms, not architecture
- Business logic is isolated from persistence, UI, and infrastructure
- Explicit composition roots
- High testability without mocking the domain
2. High-level structure
/apps
/api # NestJS API (delivery mechanism)
/website # Next.js website (delivery mechanism)
/electron # Electron app (delivery mechanism)
/core # Business rules (framework-agnostic)
/analytics
/automation
/identity
/media
/notifications
/racing
/social
/shared
/adapters # Technical implementations (details)
/persistence
/typeorm
/inmemory
/auth
/media
/notifications
/logging
/testing # Test-only code (never shipped)
/contexts
/factories
/builders
/fakes
/fixtures
3. Dependency rule (non-negotiable)
Dependencies must only point inward:
apps → adapters → core
Forbidden:
coreimporting fromadapterscoreimporting fromapps- domain entities importing ORM, NestJS, or framework code
4. Core rules
The Core contains only business rules.
Core MAY contain:
- Domain entities
- Value objects
- Domain services
- Domain events
- Repository interfaces
- Application use cases
- Application-level ports
Core MUST NOT contain:
- ORM entities
- Persistence implementations
- In-memory repositories
- NestJS decorators
- TypeORM decorators
- HTTP / GraphQL / IPC concerns
- Faker, demo data, seeds
5. Domain entities
Domain entities:
- Represent business concepts
- Enforce invariants
- Contain behavior
- Are immutable or controlled via methods
Example characteristics:
- Private constructors
- Static factory methods
- Explicit validation
- Value objects for identity
Domain entities must never:
- Be decorated with
@Entity,@Column, etc. - Be reused as persistence models
- Know how they are stored
6. Persistence entities (ORM)
Persistence entities live in adapters and are data-only.
/adapters/persistence/typeorm/<context>
- PageViewOrmEntity.ts
Rules:
- No business logic
- No validation
- No behavior
- Flat data structures
ORM entities are not domain entities.
7. Mapping (anti-corruption layer)
Mapping between domain and persistence is explicit and isolated.
/adapters/persistence/typeorm/<context>
- PageViewMapper.ts
Rules:
- Domain ↔ ORM mapping only happens in adapters
- Mappers are pure functions
- Boilerplate is acceptable and expected
8. Repositories
Core
/core/<context>/domain/repositories
- IPageViewRepository.ts
Only interfaces.
Adapters
/adapters/persistence/typeorm/<context>
- PageViewTypeOrmRepository.ts
/adapters/persistence/inmemory/<context>
- InMemoryPageViewRepository.ts
Rules:
- Repositories translate between domain entities and storage models
- Repositories implement core interfaces
- Repositories hide all persistence details from the core
9. In-memory repositories
In-memory repositories are test adapters, not core infrastructure.
Rules:
- Never placed in
/core - Allowed only in
/adapters/persistence/inmemory - Used for unit tests and integration tests
- Must behave like real repositories
10. NestJS API (/apps/api)
The NestJS API is a delivery mechanism.
Responsibilities:
- Controllers
- DTOs
- Request validation
- Dependency injection
- Adapter selection (prod vs test)
Forbidden:
- Business logic
- Domain rules
- Persistence logic
NestJS modules are composition roots.
11. Dependency injection
DI is configured only in apps.
Example:
provide: IPageViewRepository
useClass: PageViewTypeOrmRepository
Switching implementations (e.g. InMemory vs TypeORM) happens outside the core.
12. Testing strategy
Tests are clients of the system, not part of the architecture.
Core principles
/testscontains test cases/testingcontains test support only (helpers, factories, fixtures)- Production code must never import from
/testing
13. Test support structure (/testing)
The /testing folder is a test-support library, not a layer.
It follows a single, strict structure:
/testing
/fixtures # Static reference data (NO logic, NO faker)
/factories # Domain object factories (faker allowed ONLY here)
/fakes # Fake implementations of external ports
/helpers # Generic test utilities (time, ids, assertions)
index.ts
14. Rules for test support
The project uses factories only. There is no separate concept of fixtures.
All test data is produced via factories. This avoids duplication, drift, and ambiguity.
Factories (the single source of test data)
Factories are the only approved way to create test data.
Rules:
- Factories create valid domain objects or valid DTOs (boundary only)
- One factory per concept
- Stateless
- One export per file
- File name === exported symbol
- Faker / randomness allowed here
Factories must NOT:
- Use repositories
- Perform IO
- Contain assertions
- Contain domain logic
Fakes
Fakes replace external systems only.
Rules:
- Implement application ports
- Explicit behavior
- One export per file
- No domain logic
Helpers
Helpers are pure utilities.
Rules:
- No domain knowledge
- No adapters
- No randomness
Factories
- Create valid domain objects
- Stateless
- Deterministic unless randomness is required
- Faker is allowed only here
Factories must NOT:
- Use repositories
- Use DTOs
- Perform IO
Fakes
- Implement application ports
- Replace external systems (payments, email, auth, etc.)
- Explicit, controllable behavior
Helpers
- Pure utilities
- No domain logic
- No adapters
15. DTO usage in tests (strict rule)
DTOs are boundary objects, not domain concepts.
Rules:
- Domain tests: never use DTOs
- Domain factories: never use DTOs
- Use case tests: DTOs allowed only if the use case explicitly accepts a DTO
- API / controller tests: DTOs are expected and allowed
If DTOs are needed in tests, they must be created explicitly, e.g.:
/testing/dto-factories
This makes boundary tests obvious and prevents accidental coupling.
16. Concrete pseudo examples (authoritative)
The following examples show the only intended usage, aligned with Screaming Architecture and one-export-per-file.
Naming and placement are part of the rule.
Naming rules (strict)
- One file = one export
- File name === exported symbol
- PascalCase only
- No suffixes like
.factory,.fixture,.fake
Correct:
PageViewFactory.ts
PageViewFixture.ts
FakePaymentGateway.ts
Example: Domain entity
// core/analytics/domain/entities/PageView.ts
export class PageView {
static create(props) {}
isMeaningfulView(): boolean {}
}
Example: Fixture (static data)
// testing/analytics/fixtures/PageViewFixture.ts
export const PageViewFixture = {
id: 'pv-1',
entityType: 'league',
entityId: 'l-1',
visitorType: 'guest',
sessionId: 's-1',
};
Rules demonstrated:
- Plain object
- One export
- No logic
- No faker
Example: Factory (domain object creation)
// testing/analytics/factories/PageViewFactory.ts
export class PageViewFactory {
static create(overrides = {}) {
return PageView.create({
id: randomId(),
entityType: 'league',
entityId: randomId(),
visitorType: 'guest',
sessionId: randomId(),
...overrides,
});
}
}
Rules demonstrated:
- Produces domain entity
- Faker / randomness allowed here
- One export per file
- No DTOs
- No repositories
Example: Fake (external port)
// testing/racing/fakes/FakePaymentGateway.ts
export class FakePaymentGateway implements PaymentGatewayPort {
charge() {
return { success: true };
}
}
Rules demonstrated:
- Implements a port
- Simulates external system
- One export per file
- No domain logic
Example: Domain test
// tests/analytics/PageView.spec.ts
const pageView = PageViewFactory.create({ durationMs: 6000 });
expect(pageView.isMeaningfulView()).toBe(true);
Rules demonstrated:
- Talks domain language
- No DTOs
- No framework
Example: Use case test (DTO allowed only if required)
// tests/analytics/RecordPageView.spec.ts
const command = RecordPageViewCommandDTO.create({ ... });
useCase.execute(command);
Rules demonstrated:
- DTO only used because it is the explicit input
- Boundary is under test
Example: API test
// tests/api/analytics.e2e.spec.ts
POST /api/page-views
body = RecordPageViewCommandDTO
Rules demonstrated:
- DTOs expected
- HTTP boundary
- Full stack
17. Migration vs Bootstrap (production data)
Certain data exists not because of business rules, but because the application must be operable. This is not a testing concern and not a domain concern.
Migrations
Purpose:
- Create or evolve the database schema
Characteristics:
- Tables, columns, indices, constraints
- Versioned
- Deterministic
- Idempotent per version
Rules:
- No domain logic
- No factories
- No faker
- No test helpers
- No business decisions
Location:
/adapters/persistence/migrations
Bootstrap (initial application data)
Purpose:
- Ensure the system is usable after first start
Examples:
- Initial admin user
- System roles
- Required system configuration entries
Characteristics:
- Runs at application startup
- Idempotent
- Environment-driven
Rules:
- Must NOT live in core
- Must NOT live in testing
- Must NOT live in repositories
- Must use application use cases
Location:
/adapters/bootstrap
Example (pseudo):
class EnsureInitialAdminUser {
run(): Promise<void> {}
}
The application (e.g. NestJS) decides when this runs. Adapters decide how it runs.
Hard separation
| Concern | Purpose | Location |
|---|---|---|
| Migration | Schema | adapters/persistence/migrations |
| Bootstrap | Required start data | adapters/bootstrap |
| Factory | Test data | testing |
18. Final enforcement rules
- Domain knows nothing about migrations or bootstrap
- Repositories never auto-create data
- Factories are test-only
- Bootstrap is explicit and idempotent
- Migrations and bootstrap are never mixed
This section is authoritative.