Application Services and Ports
Application services are the most misunderstood layer in DDD. They're not where business logic lives (that's the domain). They're not where HTTP concerns live (that's the adapter). They're the thin orchestration layer that says: "fetch the aggregate, call the method, save it, publish the events." That's it.
When teams don't understand this, business logic leaks into controllers, or framework code leaks into the domain. Either way, you lose the ability to test the domain without starting a web server.
The Hexagonal Structure
Hexagonal architecture (ports and adapters) gives DDD's application layer a concrete shape. Your domain is at the centre. Application services surround it and define what the domain can do. Ports are interfaces — the contracts for what the application needs from the outside world. Adapters implement those interfaces using real technology.
The domain never imports from adapters. The adapters implement the ports. Application services depend on ports — they're injected with the real adapter at runtime.
Application Service: The Full Recipe
// Port (interface — lives in domain or application layer)
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
}
public interface DomainEventPublisher {
void publish(DomainEvent event);
void publishAll(List<DomainEvent> events);
}
// Application Service (orchestration only — no business logic)
@Service
@Transactional
public class OrderApplicationService {
private final OrderRepository orderRepository;
private final DomainEventPublisher eventPublisher;
private final CustomerRepository customerRepository;
public OrderApplicationService(
OrderRepository orderRepository,
DomainEventPublisher eventPublisher,
CustomerRepository customerRepository
) {
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
this.customerRepository = customerRepository;
}
public OrderId placeOrder(PlaceOrderCommand command) {
// 1. Validate existence of referenced entities (IDs)
Customer customer = customerRepository.findById(command.customerId())
.orElseThrow(() -> new CustomerNotFoundException(command.customerId()));
// 2. Create the aggregate — business logic inside Order constructor
Order order = Order.create(
OrderId.generate(),
customer.getId(),
command.lineItems()
);
// 3. Save
orderRepository.save(order);
// 4. Publish events collected by the aggregate
eventPublisher.publishAll(order.pullDomainEvents());
return order.getId();
}
public void confirmOrder(ConfirmOrderCommand command) {
Order order = orderRepository.findById(command.orderId())
.orElseThrow(() -> new OrderNotFoundException(command.orderId()));
// Business logic is in the aggregate, not here
order.confirm();
orderRepository.save(order);
eventPublisher.publishAll(order.pullDomainEvents());
}
}Notice what the application service does NOT do:
- No business rules (those are in
Order.confirm()) - No HTTP status codes
- No JSON serialization
- No logging framework calls beyond basic audit
The Driving Adapter: REST Controller
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderApplicationService orderService;
@PostMapping
public ResponseEntity<Map<String, String>> placeOrder(
@RequestBody @Valid PlaceOrderRequest request
) {
PlaceOrderCommand command = PlaceOrderCommandMapper.from(request);
OrderId orderId = orderService.placeOrder(command);
return ResponseEntity.created(URI.create("/orders/" + orderId.value()))
.body(Map.of("orderId", orderId.value()));
}
@PostMapping("/{orderId}/confirm")
public ResponseEntity<Void> confirmOrder(@PathVariable String orderId) {
orderService.confirmOrder(new ConfirmOrderCommand(new OrderId(orderId)));
return ResponseEntity.ok().build();
}
}The controller maps HTTP concerns (request body, path variables, response codes) to commands. It calls the application service. It maps the result back to HTTP. Nothing more.
The Driven Adapter: Repository Implementation
// Port (interface in application layer)
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}
// Adapter (implements port using real technology)
class PostgresOrderRepository implements OrderRepository {
constructor(private readonly db: DatabaseClient) {}
async findById(id: OrderId): Promise<Order | null> {
const row = await this.db.query(
'SELECT * FROM orders WHERE id = $1',
[id.value]
);
if (!row) return null;
return OrderMapper.toDomain(row); // maps raw DB row to domain object
}
async save(order: Order): Promise<void> {
const record = OrderMapper.toRecord(order);
await this.db.upsert('orders', record);
}
}The domain never knows about PostgreSQL. The repository port lives in the application layer. The PostgreSQL implementation lives in the infrastructure layer. Swap PostgreSQL for DynamoDB without touching the domain.
Testing Without Infrastructure
This structure makes unit testing trivial. You don't need Spring, a database, or Kafka to test your application service.
@ExtendWith(MockitoExtension.class)
class OrderApplicationServiceTest {
@Mock OrderRepository orderRepository;
@Mock DomainEventPublisher eventPublisher;
@Mock CustomerRepository customerRepository;
@InjectMocks OrderApplicationService service;
@Test
void confirmOrder_publishesOrderConfirmedEvent() {
Order draftOrder = Order.create(OrderId.generate(), customerId, someItems);
when(orderRepository.findById(draftOrder.getId()))
.thenReturn(Optional.of(draftOrder));
service.confirmOrder(new ConfirmOrderCommand(draftOrder.getId()));
verify(orderRepository).save(argThat(o -> o.getStatus() == OrderStatus.CONFIRMED));
verify(eventPublisher).publishAll(argThat(events ->
events.stream().anyMatch(e -> e instanceof OrderConfirmed)
));
}
}No Spring context. No database. Test runs in milliseconds. This is the payoff for the port/adapter split.
Key Takeaways
- Application services orchestrate: fetch the aggregate, call the domain method, save, publish events — no business logic lives here.
- Ports are interfaces defined in the application or domain layer; adapters implement them in the infrastructure layer — the domain never imports from infrastructure.
- The driving adapter (controller, consumer) maps external protocols to commands and delegates to the application service.
- The driven adapter (repository, publisher) implements ports using real technology — swap implementations without touching the domain.
- Unit tests of application services use mocked ports and run without any infrastructure, keeping feedback loops fast.
- Commands are simple data objects — they carry no logic, only the inputs a use case needs to do its job.