Core and Shell
API services use a core and shell split to keep business rules isolated from frameworks and infrastructure.
Intent
The goal is to make use cases:
- Easier to test
- Reusable across multiple transport layers
- Independent from transport and vendor details
Responsibilities
Core
The core layer contains:
- Domain types and value objects
- Business rules and validations
- Client error codes (see Errors)
- Ports such as repositories and event handlers
The core should not know about GraphQL, Fastify, databases, SDK clients, or vendor-specific exceptions.
Shell
The shell layer contains:
- Infrastructure adapters that implement core ports
- Transport layers such as GraphQL resolvers and HTTP routers
- Mapping code between domain results and transport responses
- Commands (writes) and queries (reads) that orchestrate use cases
Handlers live under shell/cqrs/:
shell/
cqrs/
commands/ # extends Command from @repo/core-server/cqrs
queries/ # extends Query from @repo/core-server/cqrs
infra/ # adapters (repositories, clients)
transport/ # GraphQL resolvers, REST routersCommands and queries extend Command and Query from @repo/core-server/cqrs. Both base classes extend Workflow, which requires a single execute(input) method. Dependencies are injected through the constructor; handlers are instantiated once per module and reused across requests.
| Kind | Base class | Purpose |
|---|---|---|
| Query | Query | Read state, no side effects |
| Command | Command | Mutate state or trigger side effects |
See Modules for how handlers are wired into a domain module and exposed on the app container.
Example
The example below follows the season-card domain in the loyalty service.
Core
// core/side-effects.ts
export interface PresenceRepository {
findByCardId: (cardId: string) => Promise<SeasonCardPresence | null>;
}// core/presence.ts - domain types and business rules
export const RenewalStatus = {
Eligible: "Eligible",
AtRisk: "AtRisk",
Ineligible: "Ineligible",
} as const;Shell query
Queries read through ports. Input and output types are declared in a namespace on the class:
// shell/cqrs/queries/presence.query.ts
import { Query } from "@repo/core-server/cqrs";
export declare namespace PresenceQuery {
export type Input = { cardId: string };
export type Output = Promise<SeasonCardPresence | null>;
}
export class PresenceQuery extends Query<PresenceQuery.Input, PresenceQuery.Output> {
constructor(private readonly presenceRepository: PresenceRepository) {
super();
}
execute = async (input: PresenceQuery.Input): PresenceQuery.Output => {
return await this.presenceRepository.findByCardId(input.cardId);
};
}Shell command
Commands mutate state through ports:
// shell/cqrs/commands/simulate-presence.command.ts
import { Command } from "@repo/core-server/cqrs";
export declare namespace SimulatePresenceCommand {
export type Input = {
cardId: string;
renewalStatus: RenewalStatus;
rewardStatus: RewardStatus;
};
export type Output = Promise<void>;
}
export class SimulatePresenceCommand extends Command<
SimulatePresenceCommand.Input,
SimulatePresenceCommand.Output
> {
constructor(
private readonly presenceRepository: PresenceRepository,
private readonly matchRepository: MatchRepository,
) {
super();
}
execute = async (
input: SimulatePresenceCommand.Input,
): SimulatePresenceCommand.Output => {
const matches = await this.matchRepository.getEredivisieHomeMatches();
await this.presenceRepository.upsert(input.cardId, ...);
};
}Module wiring
Handlers are created in the domain module factory and exposed on handlers:
// season-card.module.ts
return {
providers: { seasonCards, matches, presence },
handlers: {
presence: new PresenceQuery(presence),
seasonCards: new SeasonCardsQuery(seasonCards),
simulatePresence: new SimulatePresenceCommand(presence, matches),
},
};Transport layers
Transport layers stay thin: they call execute on the handler from the request container. Transport-specific mapping stays in the resolver or router.
// shell/transport/graphql/fields/user-season-cards.ts
return await ctx.container.seasonCard.seasonCards.execute({ ssoId: user.id });// shell/transport/rest/presence/router.ts
await request.container.seasonCard.simulatePresence.execute({
cardId: request.query.cardId,
renewalStatus: request.query.renewalStatus,
rewardStatus: request.query.rewardStatus,
});Infrastructure failures thrown inside a handler bubble up to the error middleware in @repo/core-server. No per-route or per-resolver catch is required for domain errors. See Errors for how client errors are modelled and mapped per transport.
Why this helps
- Transport layers stay thin
- Infrastructure can change without rewriting domain logic
- Commands and queries are shared across GraphQL, REST, and future transports
- Handler input/output types are explicit and scoped to each use case