Loading learning content...
Ask a developer what a 'service' is in their codebase, and you'll get different answers. One developer's 'domain service' is another's 'application service.' This confusion isn't just semantic pedantry—it leads to misplaced logic, leaky abstractions, and tangled architectures.
The distinction between Domain Services and Application Services is one of the most important—and most frequently misunderstood—concepts in DDD. Getting it wrong means putting domain logic where it doesn't belong, or worse, scattering business rules across multiple layers where they become inconsistent and unmaintainable.
This page will make the distinction crystal clear, with concrete examples and decision frameworks you can apply immediately.
By the end of this page, you will understand the fundamental difference between Domain Services and Application Services, know which type of logic belongs in each, and have a clear decision framework for placing new services correctly in your architecture.
At its core, the distinction is about what the service cares about:
Domain Service: Encapsulates business logic and domain rules that don't belong to a single entity. It knows domain concepts and enforces domain invariants.
Application Service: Orchestrates the use of domain objects to fulfill application use cases. It knows application workflow but delegates business rules to the domain.
Think of it this way: A Domain Service answers the question "How does the business work?" An Application Service answers the question "How does the application coordinate work?"
Here's a quick heuristic: Would domain experts care about this logic? If yes, it's probably domain logic (entity or domain service). If the logic is purely about application mechanics (authorization, transactions, converting DTOs)—things domain experts don't think about—it's application service territory.
Let's trace a complete money transfer use case through both service types to see how responsibilities divide:
Scenario: A REST API receives a request to transfer $100 from account A to account B.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// ============================================// Controller (Infrastructure Layer)// ============================================@Controller('/transfers')class TransferController { constructor(private transferAppService: TransferApplicationService) {} @Post() async createTransfer(@Body() dto: CreateTransferDto): Promise<TransferResponseDto> { // Controller only handles HTTP concerns const result = await this.transferAppService.executeTransfer( dto.sourceAccountId, dto.destinationAccountId, dto.amount, dto.currency ); return TransferResponseDto.fromDomain(result); }} // ============================================// Application Service (Application Layer)// ============================================@Injectable()class TransferApplicationService { constructor( private accountRepository: AccountRepository, private fundsTransferService: FundsTransferService, // Domain Service private unitOfWork: UnitOfWork, private authorizationService: AuthorizationService, private eventBus: EventBus ) {} @Transactional() // Transaction boundary - Application responsibility async executeTransfer( sourceId: string, destId: string, amount: number, currency: string ): Promise<TransferResult> { // 1. Authorization - Application concern await this.authorizationService.ensureCanTransfer( this.currentUser, sourceId ); // 2. Load domain objects const source = await this.accountRepository.findById(new AccountId(sourceId)); const destination = await this.accountRepository.findById(new AccountId(destId)); if (!source || !destination) { throw new AccountNotFoundError(); } // 3. Create domain value object const money = Money.of(amount, Currency.from(currency)); // 4. DELEGATE to Domain Service for business logic const result = this.fundsTransferService.transfer(source, destination, money); // 5. Persist changes await this.accountRepository.save(source); await this.accountRepository.save(destination); // 6. Publish integration events - Application concern await this.eventBus.publish(new TransferCompletedEvent(result)); return result; }}1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// ============================================// Domain Service (Domain Layer)// ============================================ // Interface defined in domain layerinterface FundsTransferService { transfer(source: Account, destination: Account, amount: Money): TransferResult;} // Implementation - pure domain logicclass FundsTransferServiceImpl implements FundsTransferService { constructor( private transferPolicy: TransferPolicyProvider, private feeCalculator: TransferFeeCalculator // Another domain service ) {} transfer(source: Account, destination: Account, amount: Money): TransferResult { // ALL business rules live here in the domain // 1. Check transfer policy (domain rule) const policy = this.transferPolicy.getPolicyFor(source, destination); if (!policy.allowsTransfer(amount)) { throw new TransferPolicyViolationError(policy.reason); } // 2. Check source account constraints (domain rule) if (!source.canWithdraw(amount)) { throw new InsufficientFundsError(source.id, amount); } // 3. Check destination account constraints (domain rule) if (!destination.canReceive(amount)) { throw new AccountCannotReceiveError(destination.id, amount); } // 4. Calculate fees (domain calculation) const fees = this.feeCalculator.calculateFees(source, destination, amount); // 5. Execute the domain operation source.withdraw(amount.add(fees)); destination.credit(amount); // 6. Return domain result return new TransferResult( TransferId.generate(), source.id, destination.id, amount, fees, TransferStatus.COMPLETED ); }}Notice the clean separation:
The Application Service uses the Domain Service but doesn't replicate its logic. The Domain Service knows nothing about HTTP, transactions, or event buses.
Let's examine the most common mistakes developers make when distinguishing these service types.
Mistake 1: Putting Business Logic in Application Services
This is the most common error—and it leads directly to the Anemic Domain Model anti-pattern:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// ❌ WRONG: Business logic in Application Serviceclass OrderApplicationService { async placeOrder(dto: PlaceOrderDto): Promise<OrderDto> { // Authorization - OK, this belongs here await this.authService.ensureCanPlaceOrder(this.currentUser); // Business logic - WRONG, should be in domain if (dto.items.length === 0) { throw new Error("Order must have items"); // Domain rule leaked! } // More business logic - WRONG let total = 0; for (const item of dto.items) { const product = await this.productRepo.findById(item.productId); if (product.stock < item.quantity) { throw new Error("Insufficient stock"); // Domain rule leaked! } total += product.price * item.quantity; } // Discount logic - WRONG if (total > 100) { total = total * 0.9; // Domain rule leaked! } // ... save order ... }} // ✅ CORRECT: Domain logic in Domain layerclass OrderApplicationService { async placeOrder(dto: PlaceOrderDto): Promise<OrderDto> { await this.authService.ensureCanPlaceOrder(this.currentUser); // Create domain objects const order = Order.create(this.currentUser.customerId); for (const item of dto.items) { const product = await this.productRepo.findById(item.productId); order.addItem(product, item.quantity); // Domain validates internally } // Let domain service handle business logic this.orderPricingService.applyPricing(order); // Domain service await this.inventoryService.reserveForOrder(order); // Domain service await this.orderRepo.save(order); return OrderDto.from(order); }}Mistake 2: Making Domain Services Depend on Infrastructure
Domain Services should be pure domain. When they know about frameworks, databases, or external services, they become tangled with infrastructure:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// ❌ WRONG: Domain Service with infrastructure dependenciesclass PricingDomainService { constructor( private httpClient: HttpClient, // ❌ Infrastructure! private database: Database, // ❌ Infrastructure! private logger: Logger // ❌ Infrastructure! ) {} async calculatePrice(order: Order): Promise<Money> { this.logger.info("Calculating price..."); // ❌ Infrastructure concern // Direct database access from domain const rates = await this.database.query( 'SELECT * FROM tax_rates WHERE region = ?', [order.region] ); // ❌ SQL in domain! // HTTP call from domain const exchangeRate = await this.httpClient.get( 'https://api.exchange.com/rate' ); // ❌ HTTP in domain! // calculation... }} // ✅ CORRECT: Domain Service with domain abstractionsinterface TaxRateProvider { getRatesFor(region: Region): TaxRate[];} interface ExchangeRateProvider { getRate(from: Currency, to: Currency): ExchangeRate;} class PricingDomainService { constructor( private taxRateProvider: TaxRateProvider, // Domain interface private exchangeProvider: ExchangeRateProvider // Domain interface ) {} calculatePrice(order: Order): Money { const rates = this.taxRateProvider.getRatesFor(order.region); const rate = this.exchangeProvider.getRate(order.currency, baseCurrency); // Pure domain calculation using domain abstractions return this.computePrice(order, rates, rate); }}Mistake 3: Using Domain Services for CRUD Operations
Simple create, read, update, delete operations don't need Domain Services:
1234567891011121314151617181920212223242526272829303132
// ❌ WRONG: Domain Service for simple CRUDclass CustomerDomainService { createCustomer(name: string, email: string): Customer { return new Customer(name, email); // This is just construction } updateCustomerEmail(customer: Customer, email: string): void { customer.email = email; // This should be a method ON Customer }} // ✅ CORRECT: Entity handles its own simple operationsclass Customer { constructor( private name: CustomerName, private email: EmailAddress ) {} changeEmail(newEmail: EmailAddress): void { this.email = newEmail; // Possibly raise CustomerEmailChanged event }} // Application Service for the use caseclass CustomerApplicationService { async changeEmail(customerId: string, newEmail: string): Promise<void> { const customer = await this.customerRepo.findById(customerId); customer.changeEmail(new EmailAddress(newEmail)); await this.customerRepo.save(customer); }}Just because a class ends in 'Service' doesn't define what type it is. A class called 'OrderService' could be a domain service, application service, or infrastructure service. The name alone tells you nothing—the responsibilities and dependencies do.
Here's a systematic decision framework for placing services correctly. For each potential service, ask these questions in order:
| Type of Logic | Where It Goes | Example |
|---|---|---|
| Single entity's behavior | Entity method | order.cancel(), customer.changeAddress() |
| Use case orchestration | Application Service | PlaceOrderAppService, CheckoutAppService |
| Transaction management | Application Service | @Transactional decorator or explicit UoW |
| Authorization | Application Service | Before calling domain logic |
| Business calculation across objects | Domain Service | PricingService, MatchingService |
| Domain rules spanning aggregates | Domain Service | FundsTransferService |
| External API integration | Infrastructure Service | ExternalPaymentGateway implementation |
| Database access | Repository implementation | PostgresOrderRepository |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// Complete layer separation example // DOMAIN LAYER -----------------------------// 1. Entity with its own logicclass Order { private items: OrderItem[]; private status: OrderStatus; addItem(product: Product, qty: Quantity): void { /* entity logic */ } cancel(): void { /* entity logic */ } calculateSubtotal(): Money { /* entity can calculate from own data */ }} // 2. Domain Service for cross-entity logicinterface ShippingCostCalculator { calculate(order: Order, destination: Address, carrier: Carrier): Money;} // 3. Domain Service for complex business rulesinterface InventoryAllocationService { allocateInventory(order: Order): AllocationResult;} // APPLICATION LAYER ------------------------class PlaceOrderApplicationService { constructor( private orderRepo: OrderRepository, private inventoryService: InventoryAllocationService, // Domain private shippingCalculator: ShippingCostCalculator, // Domain private paymentGateway: PaymentGateway, // Infra interface private emailSender: EmailSender // Infra interface ) {} @Transactional() // ← Application concern @Authorized(['place-order']) // ← Application concern async placeOrder(dto: PlaceOrderDto): Promise<OrderDto> { // Orchestration, not business logic const order = Order.create(dto.customerId); for (const item of dto.items) { order.addItem(/* ... */); } // Delegate to domain services const allocation = await this.inventoryService.allocateInventory(order); const shipping = this.shippingCalculator.calculate( order, dto.destination, dto.carrier ); // Delegate to infrastructure await this.paymentGateway.charge(order.total()); await this.orderRepo.save(order); await this.emailSender.sendOrderConfirmation(order); // Infra return OrderDto.from(order); }} // INFRASTRUCTURE LAYER ---------------------// Implements domain interface with infrastructureclass StripePaymentGateway implements PaymentGateway { async charge(amount: Money): Promise<PaymentResult> { // Stripe API calls - pure infrastructure }}Understanding the difference between Domain and Application Services is easier when you visualize the dependency direction in a layered architecture:
The Dependency Rule: Higher-level layers depend on lower-level layers. The domain layer depends on nothing external (it's the core).
[ Controllers / UI ] → HTTP, views, CLI
↓
[ Application Services ] → Use cases, orchestration, transactions
↓
[ Domain Services ] → Business logic, domain operations
↓
[ Entities / VOs ] → Core domain concepts, business rules
↓
[ Domain Interfaces ] → Contracts for infrastructure
↑
[ Infrastructure ] → DB, APIs, files (IMPLEMENTS domain interfaces)
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// ============================================// DOMAIN LAYER - knows ONLY domain// ============================================ // domain/services/funds-transfer-service.tsimport { Account } from '../entities/account';import { Money } from '../value-objects/money';import { TransferPolicy } from '../policies/transfer-policy';// ❌ NEVER: import { TransferAppService } from '@app/services';// ❌ NEVER: import { Database } from 'typeorm';// ❌ NEVER: import { HttpClient } from '@nestjs/common'; export class FundsTransferService { // Pure domain implementation} // ============================================// APPLICATION LAYER - coordinates all// ============================================ // application/services/transfer-app-service.tsimport { FundsTransferService } from '@domain/services'; // ✅ Uses domainimport { AccountRepository } from '@domain/repositories'; // ✅ Domain interfaceimport { TransferResult } from '@domain/results'; // ✅ Domain typeimport { TransferDto } from '../dtos/transfer-dto'; // ✅ App layer DTOimport { Transactional } from '@infrastructure/decorators'; // ✅ Uses infra export class TransferApplicationService { constructor( private domainService: FundsTransferService, // Domain dependency private accountRepo: AccountRepository // Domain interface ) {}} // ============================================// INFRASTRUCTURE - implements domain interfaces// ============================================ // infrastructure/repositories/postgres-account-repository.tsimport { AccountRepository } from '@domain/repositories'; // ✅ Implements domainimport { Account } from '@domain/entities'; // ✅ Returns domainimport { Pool } from 'pg'; // ✅ Infra details export class PostgresAccountRepository implements AccountRepository { // Infrastructure implementation}Look at the imports of any service class. If a 'Domain Service' imports HTTP clients, database drivers, logging frameworks, or anything from your application layer—it's not a Domain Service. Domain Services import only from the domain layer.
The distinction between Domain and Application Services has profound implications for testing—and correct separation makes testing dramatically easier.
Domain Service Testing:
Domain Services contain pure business logic with domain-only dependencies. They can be tested in isolation with no infrastructure:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Domain Service Test - Fast, isolated, no infrastructuredescribe('FundsTransferService', () => { let service: FundsTransferService; let mockPolicyProvider: MockTransferPolicyProvider; let mockFeeCalculator: MockFeeCalculator; beforeEach(() => { // All dependencies are domain interfaces - easy to mock mockPolicyProvider = new MockTransferPolicyProvider(); mockFeeCalculator = new MockFeeCalculator(); service = new FundsTransferService(mockPolicyProvider, mockFeeCalculator); }); it('should transfer funds when policy allows', () => { // Arrange - pure domain objects const source = Account.createWithBalance(Money.of(1000, 'USD')); const destination = Account.create(); const amount = Money.of(100, 'USD'); mockPolicyProvider.allowsTransfer(true); mockFeeCalculator.returns(Money.of(1, 'USD')); // Act - synchronous, fast const result = service.transfer(source, destination, amount); // Assert - straightforward domain assertions expect(result.status).toBe(TransferStatus.COMPLETED); expect(source.balance).toEqual(Money.of(899, 'USD')); // 1000 - 100 - 1 fee expect(destination.balance).toEqual(Money.of(100, 'USD')); }); it('should reject transfer when insufficient funds', () => { const source = Account.createWithBalance(Money.of(50, 'USD')); const destination = Account.create(); expect(() => { service.transfer(source, destination, Money.of(100, 'USD')); }).toThrow(InsufficientFundsError); }); it('should reject transfer when policy forbids', () => { mockPolicyProvider.allowsTransfer(false, 'Daily limit exceeded'); expect(() => { service.transfer(source, destination, amount); }).toThrow(TransferPolicyViolationError); });});Application Service Testing:
Application Services require mocking more infrastructure concerns, but they test the orchestration logic:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Application Service Test - Tests orchestrationdescribe('TransferApplicationService', () => { let appService: TransferApplicationService; let mockDomainService: MockFundsTransferService; // Domain service mocked let mockAccountRepo: MockAccountRepository; let mockAuthService: MockAuthorizationService; let mockEventBus: MockEventBus; beforeEach(() => { mockDomainService = new MockFundsTransferService(); mockAccountRepo = new MockAccountRepository(); mockAuthService = new MockAuthorizationService(); mockEventBus = new MockEventBus(); appService = new TransferApplicationService( mockAccountRepo, mockDomainService, mockAuthService, mockEventBus ); }); it('should check authorization before transfer', async () => { mockAuthService.forbids('transfer'); await expect(appService.executeTransfer('A', 'B', 100, 'USD')) .rejects.toThrow(UnauthorizedError); expect(mockDomainService.wasNotCalled()).toBe(true); }); it('should orchestrate transfer correctly', async () => { const mockResult = new TransferResult(/* ... */); mockDomainService.returns(mockResult); const result = await appService.executeTransfer('A', 'B', 100, 'USD'); // Verify orchestration expect(mockAuthService.wasCalledWith('transfer', 'A')).toBe(true); expect(mockAccountRepo.findById.wasCalledWith('A')).toBe(true); expect(mockAccountRepo.findById.wasCalledWith('B')).toBe(true); expect(mockDomainService.transfer.wasCalled()).toBe(true); expect(mockAccountRepo.save.callCount).toBe(2); // Both accounts saved expect(mockEventBus.publish.wasCalledWith( expect.any(TransferCompletedEvent) )).toBe(true); });});| Aspect | Domain Service Tests | Application Service Tests |
|---|---|---|
| Speed | Very fast (milliseconds) | Fast (may have some async) |
| Dependencies mocked | Domain interfaces only | Domain services + infrastructure |
| What's tested | Business logic correctness | Orchestration correctness |
| Test isolation | Completely isolated | May need test doubles |
| Database needed? | Never | No (mocked) |
| Focus | Domain rules work correctly | Use case flow is correct |
This separation naturally aligns with the test pyramid: many fast Domain Service unit tests at the base, fewer Application Service integration tests in the middle, and few end-to-end tests at the top. If your domain logic is tangled with application concerns, you can't achieve this pyramid.
We've drawn the critical line between Domain Services and Application Services—a distinction that determines architectural cleanliness and testability.
What's next:
With the Domain vs Application Service distinction clear, we can now focus on best practices for designing Domain Services themselves. The next page provides practical design guidelines—naming conventions, interface design, implementation patterns, and common pitfalls to avoid.
You can now distinguish Domain Services from Application Services and place logic in the correct layer. Next, we'll explore practical design guidelines for building well-structured Domain Services.