Three service types, one decision: where does this method actually go. A pragmatic framework with code that shows the anemic-model trap and the fix.
A junior on my squad pinged me on a Tuesday during the DDD migration at the London agency I led engineering at. He’d opened a PR titled “small refactor”, and the diff moved chargeCustomer from Order into a class called OrderService. He asked, “this is fine right, the Order entity shouldn’t talk to Stripe?” The instinct was right. The execution wasn’t. He’d taken five lines of real domain logic out with it, the rule that says you can’t charge an order whose total is zero, and the rule that says you can’t charge twice. Yeah. That’s the trap. The anemic-model trap dressed up as cleanliness.
So this post is about three boxes everybody draws on the whiteboard and nobody seems to agree on. Domain services. Application services. Infrastructure services. Which method goes where. Here’s the framework that finally stuck for me.
When I’m staring at a method and trying to figure out where it lives, I ask one thing. Does this method need the outside world to do its job. If yes, it’s not a domain service. Full stop.
The “outside world” means the database, an HTTP client, the file system, a clock, an email service, a queue. Anything you’d have to fake in a unit test. Domain code knows nothing about those. It reasons over objects that already exist in memory and answers questions or refuses to.
// domain/orders/order-pricing.ts
import { Order } from "./order";
import { TaxRate } from "./tax-rate";
import { Money } from "../shared/money";
export class OrderPricing {
totalFor(order: Order, rate: TaxRate): Money {
const subtotal = order.lines.reduce(
(acc, l) => acc.plus(l.unitPrice.times(l.quantity)),
Money.zero(order.currency),
);
const tax = subtotal.times(rate.value);
return subtotal.plus(tax);
}
}
That’s a domain service. No async. No injection. No I/O. It exists because the rule doesn’t naturally live on Order alone (you need a TaxRate) and doesn’t belong on TaxRate either. So it’s a stateless function dressed as a class, and it stays in the domain because every byte it touches is in-memory.
Application services orchestrate. They open the transaction, load aggregates through repositories, call domain methods, save, raise events. They don’t make business rulings. The classic giveaway: an application service that has a long if ladder deciding what’s allowed. That if is a domain invariant in disguise.
// application/orders/charge-order.ts
import { Inject, Injectable } from "@nestjs/common";
import { UnitOfWork } from "../shared/unit-of-work";
import { OrderRepository } from "../../domain/orders/order.repository";
import { PaymentGateway } from "../../domain/orders/payment.gateway";
import { OrderPricing } from "../../domain/orders/order-pricing";
import { TaxRateRepository } from "../../domain/orders/tax-rate.repository";
import { ChargeFailed } from "./errors";
import { AlreadyCharged, ZeroTotal } from "../../domain/orders/errors";
@Injectable()
export class ChargeOrder {
constructor(
@Inject("UnitOfWork") private readonly uow: UnitOfWork,
@Inject("OrderRepository") private readonly orders: OrderRepository,
@Inject("TaxRateRepository") private readonly taxRates: TaxRateRepository,
@Inject("PaymentGateway") private readonly payments: PaymentGateway,
private readonly pricing: OrderPricing,
) {}
async execute(input: { orderId: string }): Promise<{ chargeId: string }> {
return this.uow.run(async () => {
const order = await this.orders.load(input.orderId);
if (!order) throw new ChargeFailed("not_found");
const rate = await this.taxRates.for(order.customerCountry);
const total = this.pricing.totalFor(order, rate);
try {
order.markChargeable(total);
} catch (e) {
if (e instanceof AlreadyCharged) throw new ChargeFailed("already_charged", e);
if (e instanceof ZeroTotal) throw new ChargeFailed("zero_total", e);
throw e;
}
const charge = await this.payments.charge(order.paymentIntent(total));
order.recordCharge(charge.id);
await this.orders.save(order);
return { chargeId: charge.id };
});
}
}
Look at where the rules live. markChargeable is the one that refuses if the total is zero or if the order was already charged. The application service doesn’t second-guess that. It catches the domain refusal and translates it into something the HTTP layer can return as a 409.
The first time I see if (order.status === 'paid') return inside an application service, I move it. That’s a business rule about what an order allows. It belongs on Order.
I keep this layer boring on purpose. Infrastructure services implement the ports that domain or application layers define. They know about TypeORM, about Stripe, about S3, about Datadog. The domain doesn’t.
// infrastructure/orders/stripe-payment.gateway.ts
import { Injectable, Logger } from "@nestjs/common";
import Stripe from "stripe";
import { PaymentGateway } from "../../domain/orders/payment.gateway";
import { PaymentIntent } from "../../domain/orders/payment-intent";
@Injectable()
export class StripePaymentGateway implements PaymentGateway {
private readonly log = new Logger(StripePaymentGateway.name);
constructor(private readonly stripe: Stripe) {}
async charge(intent: PaymentIntent): Promise<{ id: string }> {
try {
const result = await this.stripe.paymentIntents.create({
amount: intent.amount.minorUnits,
currency: intent.amount.currency.toLowerCase(),
customer: intent.customerId,
idempotency_key: intent.idempotencyKey,
confirm: true,
});
return { id: result.id };
} catch (e) {
this.log.error({ err: e, intent: intent.idempotencyKey }, "stripe charge failed");
throw e;
}
}
}
The PaymentGateway interface lives in the domain because the domain has an opinion about charging, an idempotency key, an amount, a customer. The concrete adapter is the only file in the codebase that imports the stripe SDK. I’ve swapped a payment gateway twice. Both times the only files that changed were in infrastructure/. That’s the payoff.
This is the one I tell when someone argues that “service classes are cleaner”. A nightlife discovery and ticketing product we shipped during the DDD migration at the London agency. The team had refactored Booking into a near-empty data class, and built a big BookingService with twenty methods. On a Friday night a teammate added a retry around BookingService.confirm because a flaky downstream was failing intermittently.
What we didn’t catch in review: confirm mutated the booking, called the payment gateway, mutated the booking again, and saved. The retry sat outside all of that. On the second attempt, the booking was already in confirmed state in memory, and the retry happily saved it twice with two charge ids. About thirty customers got double-billed on the busiest hour of the week. The funny thing was the codebase had BookingService everywhere and looked very organized.
First wrong fix was an early return inside confirm. That hid the visible double-charge but left the data broken because the second charge had already happened. Real fix took longer. We pushed the state transition back into Booking.confirm(), made it throw AlreadyConfirmed, wrapped the application service’s catch around it, and added idempotency keys on the gateway call. The retry now bounces off a typed exception instead of silently re-running.
About four hours of damage, plus refunds. Lesson that stuck: when business logic lives in service classes, retries and concurrency are your problem. When it lives on the aggregate, the aggregate refuses for you. Anemic models aren’t cleaner. They’re just quieter when they break.
Not every rule lives on an aggregate. If a rule needs two aggregates that don’t naturally own each other, a domain service is the right answer. Transferring money between two Account aggregates is the canonical example. Neither account “owns” the transfer. The transfer is a domain operation that takes both.
// domain/accounts/transfer.ts
import { Account } from "./account";
import { Money } from "../shared/money";
import { InsufficientFunds } from "./errors";
export class Transfer {
execute(source: Account, target: Account, amount: Money): void {
if (source.balance.lessThan(amount)) {
throw new InsufficientFunds(source.id);
}
source.debit(amount);
target.credit(amount);
}
}
Still no I/O. Still no async. The application service is the one that loads both accounts inside a transaction and saves them, but the rule itself, “you can’t transfer more than you have”, lives in the domain. That distinction is what keeps the domain layer worth having.
The combat-sports tournament platform I CTO’d in London. Rule was, registration closes 48 hours before the first bout. Someone implemented it as a static method on Tournament that called Date.now() directly. Tests passed because they all ran “now”. Production worked. Until daylight savings hit and a federation in a different timezone ran a qualifier on the boundary. Three athletes registered seventeen seconds after the cutoff, server thought it was still open. The federation was understanding. The athletes were less so.
First wrong fix was to hardcode UTC everywhere, which broke the rest of the system. Real fix was a Clock port in the domain, a SystemClock adapter in infrastructure, and tournament rules taking a now: Date parameter. The rule got testable at any point in time, daylight-savings edge had a unit test, domain stopped depending on a global. About twelve minutes of late registrations. Quiet lesson: anything the domain reads from the outside world is a port. Even time.
Thanks for reading. If you’ve got thoughts, send them my way.