Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save JonathanTurnock/53504402aa342b66d472f40963de2d6c to your computer and use it in GitHub Desktop.

Select an option

Save JonathanTurnock/53504402aa342b66d472f40963de2d6c to your computer and use it in GitHub Desktop.
Hexagonal Architecture for LLMs (Typescript).md

example.svg

@startuml Hexagonal Architecture Example

title Hexagonal Architecture Example

package App {
    class ApplicationContext {
        + userRepository: UserRepository
    }

    class Application {
        - applicationContext: ApplicationContext
        + users: Users
    }

    class Users <<serviece>> {
        - userRepository: UserRepository
        + addUser()
    }

    interface UserRepository <<port>> {
        + save(user: User): User
    }

    class User <<struct>> {
        + id: string
        + name: string
    }
}

package Main {
    class MongoUserRepository <<adapter>> {
        - dbConnection: DbConnection
        + save(user: User): User
    }

    class ProdApplication {
        - userRepository: UserRepository
    }
}

note left of ProdApplication {
    An entrypoint index.ts or main.ts 
    file would bootstrap the application
    by creating the ApplicationContext with 
    the appropriate adapters and passing it to 
    the Application.
}

package Test {
    class TestUserRepository <<adapter>> {
        - users: User[]
        + save(user: User): User
    }

    class TestApplication {
        - userRepository: UserRepository
    }
}

note right of TestApplication {
    An entrypoint test file would 
    create the ApplicationContext with the 
    TestUserRepository and pass it to the
    Application for testing purposes.
}

Application --> ApplicationContext : requires
Application --> Users : contains
Users --> UserRepository : depends on
UserRepository <|.. TestUserRepository : implements
UserRepository <|.. MongoUserRepository : implements
Users --> User : manages a collection of

TestApplication ..> TestUserRepository : instantiates
ProdApplication ..> MongoUserRepository : instantiates

TestApplication <|.. Application : extends
ProdApplication <|.. Application : extends

@enduml
# llms.txt — Agentic Workflow Architecture & Testing Guidelines
# Purpose: Provide clear, machine-readable conventions for LLM-driven agentic workflows
# that implement domain logic using Hexagonal Architecture (Ports & Adapters),
# exposed via a single entrypoint, with strong testing practices.
## North Star
- Keep **domain logic pure** and independent of frameworks, IO, databases, network, and UI.
- Express all external interactions through **ports** (interfaces).
- Implement ports in **adapters** (infrastructure).
- Wire everything together in **exactly one entrypoint** (composition root).
- Test the domain through **port substitutes** (in-memory adapters / fakes), not real infra.
## Architectural Constraints (Hard Rules)
1. **Single Entrypoint (Composition Root)**
- There MUST be one production bootstrap file (e.g. `src/main.ts` or `src/index.ts`) that:
- Creates adapter instances
- Builds the `ApplicationContext`
- Instantiates `Application`
- Exposes the app’s public API (HTTP handlers, CLI, jobs, etc.)
- No other module may instantiate infrastructure adapters directly.
2. **Domain Depends Only on Ports**
- Domain services (use-cases) MUST depend only on port interfaces.
- Domain MUST NOT import:
- DB clients, ORMs, HTTP clients, SDKs, filesystem APIs, environment variables, loggers, timers
- test frameworks
- web frameworks (Express/Fastify/Next/etc.)
3. **Ports Are Stable Contracts**
- Ports must be small and purpose-driven.
- Ports define domain-shaped inputs/outputs (prefer domain types), not DB shapes.
- Ports must be synchronous or async as required, but consistent per port.
4. **Adapters Are Replaceable**
- Adapters implement ports and map from domain types to infrastructure types.
- Adapters MUST NOT contain domain policy/decision logic.
5. **No Hidden Global State**
- No module-level singletons for repositories, DB connections, or config.
- Configuration flows from the entrypoint into adapters/context.
6. **Context Is a Dependency Container, Not a Service Locator**
- `ApplicationContext` is constructed once at bootstrap and passed downward.
- Domain services receive only what they need (ports), not an entire context, deps may optionally be expressed as a type rather than long constructor params.
## Recommended Project Layout
- `src/app/`
- `entities/` (e.g. `User`)
- `ports/` (e.g. `UserRepository`)
- `services/` (use-cases, e.g. `Users`)
- `errors/` (domain errors)
- `ApplicationContext` (holds ports)
- `Application` (exposes orchestrated API/use-cases)
- `src/adapters/...` (infrastructure implementations)
- `src/adappters/**/*.test.ts` (Mockist Tests)
- `src/main.ts` (ONLY place where adapters are wired)
- `src/__tests__/`
- `adapters/` (in-memory fakes)
- `*.test.ts` (Sociable Tests for the Application API)
## Canonical Pattern (Hexagonal)
- App:
- Entities: `User`
- Port: `UserRepository`
- Service: `Users` (use-case)
- `ApplicationContext` holds `userRepository: UserRepository`
- `Application` owns `users: Users`
- Adapters:
- `MongoUserRepository implements UserRepository`
- `TestUserRepository implements UserRepository`
- Entrypoints:
- Prod entrypoint: creates `MongoUserRepository` and bootstraps `Application`
- Test entrypoint: creates `TestUserRepository` and bootstraps `Application`
## Entrypoint Requirements
Production entrypoint MUST:
- Read configuration (env/files) ONLY here
- Create infra (db connection, clients)
- Construct adapters
- Construct `ApplicationContext`
- Construct `Application`
- Export the public entry API (e.g. `createServer(app)` or `handler(app)`)
Test entrypoint MUST:
- Build `ApplicationContext` with in-memory adapters
- Instantiate `Application`
- The Tests MUST work with both the TestApplication but also the ProdApplication (With test setup)
- Tests implementations cannot use additional methods (i.e. Clearing) so must focus on fresh insantiation per test
## Testing Policy (Architecture + Practice)
### Test Pyramid (Required)
1. **Sociable Testing (Most)**
- Test `App` for maximum coverage using social testing.
- Create Two Test Cases:
- Instantiate `TestApplication` via a test entrypoint.
- Instantiate `ProdApplication` via a test entrypoint.
- Assert behavior across multiple services/use-cases using Test Iteration i.e. describe.each or test.each
2. **Adapter Contract Tests (Some)**
- For each adapter, verify it satisfies the port contract:
- persistence semantics
- error mapping
- idempotency expectations
- Use real dependencies only when necessary (e.g. ephemeral containers).
### Testing Rules (Hard)
- Do not mock domain services; mock/replace ports instead.
- Every domain service MUST be testable by constructing it with port fakes.
- Avoid snapshot testing for domain logic. Prefer explicit assertions.
- Tests MUST NOT depend on wall-clock time; inject time via a port if needed (e.g. `Clock`).
- Property-based tests are encouraged for invariant-heavy entities.
### Required Test Coverage Targets (Guidance)
- Domain services: cover happy path + key edge cases + failure mapping.
- Ports: define clear expected behavior in tests (see “Port Contracts” below).
- Entrypoint wiring: at least one integration test to ensure correct adapter wiring.
## Port Contract Documentation (Must-Have)
For each port interface, document:
- Method purpose
- Input validation expectations (domain-level)
- Return semantics (e.g. does `save` return stored entity? does it assign ids?)
- Error semantics (domain errors vs adapter errors)
- Consistency and transactional assumptions
- Performance constraints if relevant
Example (UserRepository.save):
- Accepts a domain `User`
- Persists user (create or update per domain rules)
- Returns persisted `User` (including any assigned id)
- Throws/returns `UserAlreadyExists` only if required by domain rules
## Error Handling & Mapping
- For known errors, prefer the use of Neverthrow https://www.npmjs.com/package/neverthrow
- If not available use a typed error and return it i.e. readFile(path: string): string | ReadFileError
- Throwing errors is OK but only for `unchecked` exceptions
- App defines domain errors (e.g. `UserAlreadyExists`, `ValidationError`).
- Adapters translate infra errors into domain errors (or explicit adapter errors that are mapped at the boundary).
- Entrypoint maps domain errors to transport errors (HTTP codes, CLI exit codes).
## Agentic Workflow Guidance (LLM-Specific)
When generating or modifying code:
1. **Start in the app**
- Define/adjust entities, ports, and services first.
2. **Keep IO at the edges**
- If a feature requires IO, add/extend a port.
Agent should prefer:
- use of ports
- explicit dependency injection
- avoiding global singletons
- avoid reliance on mocking
## Anti-Patterns (Disallowed)
- Importing DB/HTTP clients in domain or app services
- Creating adapters inside services/use-cases
- Reading `process.env` outside entrypoint
- Reading from a config file and then importing it
- “God context” passed everywhere
- Mocking internals of domain services instead of using ports and adapters
- Logic in adapters that decides domain outcomes
## Output Expectations
When asked to implement features:
- Provide:
- domain changes + tests
- adapter implementations (if needed)
- entrypoint wiring changes
- Avoid:
- framework-specific coupling in domain
- touching multiple composition roots (there should be one)
## See the following PlantUML example
@startuml Hexagonal Architecture Example
title Hexagonal Architecture Example
package App {
class ApplicationContext {
+ userRepository: UserRepository
}
class Application {
- applicationContext: ApplicationContext
+ users: Users
}
class Users <<serviece>> {
- userRepository: UserRepository
+ addUser()
}
interface UserRepository <<port>> {
+ save(user: User): User
}
class User <<struct>> {
+ id: string
+ name: string
}
}
package Main {
class MongoUserRepository <<adapter>> {
- dbConnection: DbConnection
+ save(user: User): User
}
class ProdApplication {
- userRepository: UserRepository
}
}
note left of ProdApplication {
An entrypoint index.ts or main.ts
file would bootstrap the application
by creating the ApplicationContext with
the appropriate adapters and passing it to
the Application.
}
package Test {
class TestUserRepository <<adapter>> {
- users: User[]
+ save(user: User): User
}
class TestApplication {
- userRepository: UserRepository
}
}
note right of TestApplication {
An entrypoint test file would
create the ApplicationContext with the
TestUserRepository and pass it to the
Application for testing purposes.
}
Application --> ApplicationContext : requires
Application --> Users : contains
Users --> UserRepository : depends on
UserRepository <|.. TestUserRepository : implements
UserRepository <|.. MongoUserRepository : implements
Users --> User : manages a collection of
TestApplication ..> TestUserRepository : instantiates
ProdApplication ..> MongoUserRepository : instantiates
TestApplication <|.. Application : extends
ProdApplication <|.. Application : extends
@enduml
# End of llms.txt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment