Skip to content

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 routers

Commands 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.

KindBase classPurpose
QueryQueryRead state, no side effects
CommandCommandMutate 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

ts
// core/side-effects.ts
export interface PresenceRepository {
	findByCardId: (cardId: string) => Promise<SeasonCardPresence | null>;
}
ts
// 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:

ts
// 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:

ts
// 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:

ts
// 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.

ts
// shell/transport/graphql/fields/user-season-cards.ts
return await ctx.container.seasonCard.seasonCards.execute({ ssoId: user.id });
ts
// 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