Last Updated: June 21, 2026 at 10:00

The Application Layer in Domain-Driven Design: Use Cases, Transactions, and the Orchestration Boundary

How application services coordinate use cases, manage transaction boundaries, and keep business logic where it belongs—in the domain.

The application layer in Domain-Driven Design is the coordinator that connects the outside world to the domain model, sitting between presentation concerns and business logic. This article covers use case structure, command and query separation, Data Transfer Objects, transaction boundary management, and—above all—the distinction between orchestration and business logic. The central insight is that the application layer must contain orchestration only: the sequence of steps required to execute a use case, never the rules that govern whether those steps are valid. Developers who internalize this boundary will find their domain models grow richer and more expressive, their tests faster and more focused, and their codebases significantly easier to change.

Image

Introduction

The application layer sits between the HTTP controllers, message consumers, and scheduled jobs that receive the world's requests, and the domain objects that contain the real logic. Its role is logistics, not judgment. It knows which aggregates to load, in what order to call domain methods, and when to commit a transaction. What it must never know is whether a given operation is actually valid—whether the customer's credit line permits this purchase, whether the account balance supports this withdrawal, whether this order can be cancelled. Those questions belong to the domain.

A useful mental model: the application layer is an airport dispatcher. The dispatcher coordinates the sequence—fuel truck, boarding, pushback—but makes no decisions about whether the aircraft is airworthy. That is the engineer's domain. When the two roles blur, accountability disappears and the system becomes hard to reason about.

This article examines the application layer in depth: its responsibilities, its relationship to transactions and DTOs, and—above all—the boundary that separates orchestration from business logic. The examples are in Java using Spring, but the principles apply across any language or framework.

How the Application Layer Fits into DDD Architecture

A well-structured DDD application is organised into four layers. Understanding where the application layer sits—and what sits above and below it—clarifies what belongs inside it.

Presentation Layer   -- HTTP controllers, CLI handlers, message listeners
Application Layer    -- Use case orchestration, transactions, DTOs
Domain Layer         -- Entities, aggregates, domain services, value objects
Infrastructure Layer -- Repositories, messaging, databases, external APIs

The presentation layer receives requests from the outside world and delegates immediately to the application layer. It does not execute business logic, and it does not speak to the domain directly.

The application layer coordinates use cases. It calls repositories, invokes domain logic, manages transactions, and returns Data Transfer Objects. It does not contain business rules.

The domain layer contains all business logic. It is pure—it knows nothing about HTTP, databases, or transactions.

The infrastructure layer provides concrete implementations of domain interfaces: JPA repositories, Kafka publishers, SMTP gateways.

The dependency direction runs inward. The presentation layer depends on the application layer. The application layer depends on domain layer interfaces. The infrastructure layer implements those interfaces. The domain layer depends on nothing outside itself.

Presentation → Application → Domain ← Infrastructure

In practice, this is enforced by keeping domain and application layer types in their own modules or packages, and ensuring that infrastructure implementations are injected at runtime via dependency injection.

The application layer is called by anything that sits above it: HTTP controllers, message consumers, scheduled jobs, CLI commands, and gRPC or GraphQL handlers. Each caller translates its incoming request into a command or query object and passes it to the appropriate application service. The caller has no knowledge of domain objects—it speaks only in terms of DTOs. Callers must not bypass the application layer to talk directly to domain objects or infrastructure; the application layer is the single authorised entry point into domain logic for all external requests.

What the Application Layer Is Responsible For

Stated precisely, the application layer is responsible for six things:

  1. Accepting command or query objects from presentation layer callers.
  2. Using repositories to load the aggregates the use case requires.
  3. Calling methods on aggregates, domain services, and factories without duplicating their logic.
  4. Beginning and committing transactions so that each use case succeeds or fails atomically.
  5. Saving changed aggregates back through repositories.
  6. Mapping domain objects to DTOs before returning them to callers.

These steps sequence into a single flow: a request enters through a caller, which translates it into a command. The application service orchestrates the use case by loading aggregates, calling domain methods that contain all business rules, and saving changes within a transaction. The response travels back as a DTO, shielding external callers from domain complexity.

It is explicitly not responsible for enforcing business rules, making domain decisions, or containing conditional logic that reflects business policy. That list of exclusions matters as much as the list of inclusions.

A new application service should be created for each meaningful use case—identified by asking what the business needs to do. Each distinct answer becomes a method, or in more granular designs, an entire class. When a single service class exceeds five or six methods, carries a long list of injected dependencies, or handles methods that share no domain objects with each other, split it into multiple focused services grouped by aggregate or subdomain.

To see the layer in context, consider a straightforward order placement flow. The HTTP controller receives the request and delegates immediately:

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final PlaceOrderApplicationService placeOrderService;

    public OrderController(PlaceOrderApplicationService placeOrderService) {
        this.placeOrderService = placeOrderService;
    }

    @PostMapping
    public ResponseEntity<PlaceOrderResponse> placeOrder(
            @RequestBody PlaceOrderRequest request,
            @AuthenticationPrincipal AuthenticatedUser user) {

        PlaceOrderCommand command = new PlaceOrderCommand(
            user.getCustomerId(),
            request.getItems(),
            request.getShippingAddress()
        );

        PlaceOrderResponse response = placeOrderService.execute(command);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
}

The application service handles coordination:

@Service
@Transactional
public class PlaceOrderApplicationService {

    private final CustomerRepository customerRepository;
    private final OrderRepository orderRepository;
    private final OrderFactory orderFactory;

    public PlaceOrderApplicationService(
            CustomerRepository customerRepository,
            OrderRepository orderRepository,
            OrderFactory orderFactory) {
        this.customerRepository = customerRepository;
        this.orderRepository = orderRepository;
        this.orderFactory = orderFactory;
    }

    public PlaceOrderResponse execute(PlaceOrderCommand command) {
        Customer customer = customerRepository.findById(command.customerId())
            .orElseThrow(() -> new CustomerNotFoundException(command.customerId()));

        Order order = orderFactory.create(customer, command.items(), command.shippingAddress());
        order.place(); // Domain logic lives inside Order, not here

        orderRepository.save(order);

        return PlaceOrderResponse.from(order);
    }
}

The service retrieves aggregates, creates a new one via a factory, calls order.place() (whose logic lives inside Order), manages a transaction, and returns a DTO. There are no business rules here, no conditional logic about order validity, no calculation of totals. Those decisions belong to the domain.

Commands and Queries: The Two Kinds of Use Cases

Use cases divide into two fundamentally different kinds, and treating them as such clarifies the structure of the application layer considerably.

Commands change the state of the system. They place orders, transfer funds, register customers, and approve invoices. Commands go through the domain layer, invoke business logic, enforce invariants, and raise domain events. They typically return minimal data—an identifier, a status indicator, or a lightweight summary.

Queries retrieve information without changing state. They return order history, account balances, and customer profiles. Because they do not enforce invariants (the business rules that must always hold true, such as "an order cannot be placed with no items"), they may bypass the domain model entirely, reading from optimised read models or query-specific projections rather than loading full aggregates.

This separation is the essence of CQRS—Command Query Responsibility Segregation. Even in applications that do not implement full event sourcing, separating commands from queries clarifies intent, improves testability, and allows each side to be optimised independently.

A command application service follows a predictable structure—load, execute, save, return:

@Service
@Transactional
public class TransferFundsApplicationService {

    private final AccountRepository accountRepository;
    private final FundsTransferDomainService transferDomainService;

    public TransferFundsApplicationService(
            AccountRepository accountRepository,
            FundsTransferDomainService transferDomainService) {
        this.accountRepository = accountRepository;
        this.transferDomainService = transferDomainService;
    }

    public TransferFundsResponse execute(TransferFundsCommand command) {
        Account source = accountRepository.findById(command.sourceAccountId())
            .orElseThrow(() -> new AccountNotFoundException(command.sourceAccountId()));

        Account destination = accountRepository.findById(command.destinationAccountId())
            .orElseThrow(() -> new AccountNotFoundException(command.destinationAccountId()));

        transferDomainService.transfer(source, destination, command.amount());

        accountRepository.save(source);
        accountRepository.save(destination);

        return new TransferFundsResponse(source.getId(), destination.getId(), command.amount());
    }
}

The service knows the sequence. It does not know the rules: what constitutes a valid transfer, whether overdraft is permitted, how currency conversion works. Those decisions live inside FundsTransferDomainService and the Account aggregate.

Query services are simpler. They do not need to load full aggregates:

@Service
@Transactional(readOnly = true)
public class GetOrderHistoryQueryService {

    private final OrderSummaryRepository orderSummaryRepository;

    public GetOrderHistoryQueryService(OrderSummaryRepository orderSummaryRepository) {
        this.orderSummaryRepository = orderSummaryRepository;
    }

    public List<OrderSummaryDTO> execute(GetOrderHistoryQuery query) {
        return orderSummaryRepository
            .findByCustomerId(query.customerId(), query.pageable())
            .stream()
            .map(OrderSummaryDTO::from)
            .toList();
    }
}

@Transactional(readOnly = true) signals the JPA provider not to flush changes and allows the database driver to apply read-optimised behaviour. The query service works with lightweight projections shaped for display, not aggregates shaped for invariant enforcement.

Command and Query Objects: Explicit Contracts for Each Use Case

Rather than accepting raw parameters, application services accept command and query objects—explicit data carriers that capture everything a use case requires.

public record PlaceOrderCommand(
    CustomerId customerId,
    List<OrderLineItem> items,
    ShippingAddress shippingAddress
) {
    public PlaceOrderCommand {
        Objects.requireNonNull(customerId, "customerId must not be null");
        Objects.requireNonNull(items, "items must not be null");
        if (items.isEmpty()) throw new IllegalArgumentException("items must not be empty");
        Objects.requireNonNull(shippingAddress, "shippingAddress must not be null");
    }
}

Commands are validated at construction. This is structural validation—checking that required fields are present and that values are of the right type. It is not business logic. Business validation—whether the customer has a valid credit line, whether the requested items are in stock—belongs in the domain layer. The distinction matters: structural validation is infrastructure hygiene; business validation is domain policy.

The same pattern applies to queries:

public record GetOrderHistoryQuery(
    CustomerId customerId,
    Pageable pageable
) {
    public GetOrderHistoryQuery {
        Objects.requireNonNull(customerId, "customerId must not be null");
        Objects.requireNonNull(pageable, "pageable must not be null");
    }
}

Modelling use cases as explicit objects makes application service interfaces self-documenting, allows commands to be serialised for messaging systems, simplifies test setup, and creates a natural record of what each use case requires.

DTOs and Why Domain Objects Must Stay Behind the Boundary

Returning domain objects directly from application services is a shortcut with compounding costs.

Domain objects contain behaviour. Exposing an Order entity to a controller means the controller can, in principle, call order.place(), order.cancel(), or any other domain method. Those operations must only be triggered through the application layer.

Domain objects are coupled to the domain model. Every change to Order—a field renamed, a method signature changed, a new invariant added—ripples directly into any external code consuming the object. DTOs provide a stable interface that can evolve independently.

Serialisation is fragile with domain objects. Rich domain objects may contain circular references, lazy-loaded collections, or internal state flags that have no meaning to external consumers. Attempting to serialise them directly with frameworks like Jackson routinely causes LazyInitializationException or infinite recursion.

A well-designed DTO is an immutable data container with no behaviour, shaped precisely for the needs of its consumer:

public record OrderSummaryDTO(
    String orderId,
    String status,
    BigDecimal totalAmount,
    String currency,
    LocalDateTime placedAt
) {
    public static OrderSummaryDTO from(Order order) {
        return new OrderSummaryDTO(
            order.getId().value(),
            order.getStatus().name(),
            order.getTotal().amount(),
            order.getTotal().currency().getCode(),
            order.getPlacedAt()
        );
    }
}

The application layer owns the mapping from domain objects to DTOs. The domain layer knows nothing about DTOs—it should never contain a toDTO() method. Mapping logic lives either in a static factory method on the DTO itself (as above) or in a dedicated mapper class. The choice between approaches is a matter of team preference; what matters is that the domain layer stays clean.

Transaction Boundaries and Why They Belong in the Application Layer

Transaction management is one of the most important responsibilities of the application layer. A transaction ensures that a use case either completes fully or rolls back entirely, leaving the system in a consistent state.

In DDD, the transaction boundary aligns with the use case. Each command runs in a single transaction. The application service is responsible for starting it, and the transaction completes when the service method returns. In Spring, this is handled declaratively with @Transactional.

The domain layer must not know about transactions. Entities and domain services contain pure business logic. If they called beginTransaction() or commit(), they would be coupled to a specific transactional mechanism—a coupling that makes them impossible to test cleanly and difficult to port to different infrastructure.

The application layer, by contrast, coordinates use cases, and a use case is a natural unit of work. The two concepts align perfectly.

Handling multiple aggregates in a single transaction deserves care. A transaction guarantees that changes within one aggregate are fully committed or fully rolled back—but that guarantee does not extend across aggregate boundaries.

When a use case needs to update two aggregates, one option is to keep both changes in a single database transaction. This works when both aggregates live in the same database, but it creates tight coupling and can mask a design problem—if two aggregates always change together, they may belong inside a single aggregate boundary.

The more DDD-aligned approach is to accept eventual consistency: commit the first aggregate's changes, then publish a domain event. A separate handler picks up that event in its own transaction and updates the second aggregate asynchronously:

@Service
@Transactional
public class PlaceOrderApplicationService {

    private final OrderRepository orderRepository;
    private final CustomerRepository customerRepository;
    private final OrderFactory orderFactory;
    private final DomainEventPublisher eventPublisher;

    public PlaceOrderResponse execute(PlaceOrderCommand command) {
        Customer customer = customerRepository.findById(command.customerId())
            .orElseThrow(() -> new CustomerNotFoundException(command.customerId()));

        Order order = orderFactory.create(customer, command.items(), command.shippingAddress());
        order.place();

        orderRepository.save(order);

        // Domain events published after transaction commits via transactional outbox
        // or Spring's @TransactionalEventListener
        eventPublisher.publishAll(order.domainEvents());
        order.clearDomainEvents();

        return PlaceOrderResponse.from(order);
    }
}

The downstream handler—running in its own transaction—processes the OrderPlacedEvent and updates other parts of the system: reserving inventory, triggering fulfilment, notifying the customer.

When a domain invariant is violated, the domain layer raises a domain exception. In Spring, @Transactional rolls back automatically for unchecked exceptions. A global @ExceptionHandler in the presentation layer translates the domain exception into an appropriate HTTP response. The application service itself rarely needs to catch exceptions—propagation handles most scenarios cleanly.

Orchestration vs. Business Logic: The Most Important Boundary in the Application Layer

Of all the principles that govern the application layer, this distinction is the one that determines whether DDD's core promise holds or collapses.

Orchestration is the coordination of activities. It decides what steps to take and in what order: load this aggregate, call this method, save those changes, return this result. Orchestration knows how the use case unfolds.

Business logic is the rules and decisions that reflect business policy. It calculates totals, validates states, enforces constraints, and determines what is and is not permitted. Business logic knows why things are done.

The application layer must contain orchestration only.

A useful diagnostic: if a piece of logic were extracted into a separate module with no knowledge of HTTP, databases, or transactions, would it still make complete sense in terms of the business? If yes—it expresses a business rule and belongs in the domain. If no—it coordinates infrastructure and belongs in the application layer.

Orchestration code is procedural. Each line delegates to the domain:

// Application service — pure orchestration
public PlaceOrderResponse execute(PlaceOrderCommand command) {

    // Step 1: Load aggregates
    Customer customer = customerRepository.findById(command.customerId())
        .orElseThrow(() -> new CustomerNotFoundException(command.customerId()));

    // Step 2: Create via factory (construction logic lives in factory/domain)
    Order order = orderFactory.create(customer, command.items(), command.shippingAddress());

    // Step 3: Invoke domain behaviour (rules live inside Order)
    order.place();

    // Step 4: Apply discount (rules live inside domain service)
    loyaltyDiscountService.applyIfEligible(order, customer);

    // Step 5: Persist
    orderRepository.save(order);

    // Step 6: Return result
    return PlaceOrderResponse.from(order);
}

Business logic lives inside aggregates and domain services:

// Inside Order — domain entity
public void place() {
    if (this.status != OrderStatus.DRAFT) {
        throw new OrderNotPlaceableException(
            "Order " + this.id + " cannot be placed from status " + this.status
        );
    }
    if (this.lines.isEmpty()) {
        throw new EmptyOrderException("Cannot place an order with no line items");
    }
    this.status = OrderStatus.PLACED;
    this.placedAt = Instant.now();
    this.registerDomainEvent(new OrderPlacedEvent(this.id, this.customerId, this.total));
}
// Inside LoyaltyDiscountDomainService — domain service
public void applyIfEligible(Order order, Customer customer) {
    if (customer.getLoyaltyTier() == LoyaltyTier.GOLD
            && order.getTotal().isGreaterThan(GOLD_THRESHOLD)) {
        Money discount = order.getTotal().percentage(GOLD_DISCOUNT_RATE);
        order.applyDiscount(discount);
    }
}

When this boundary erodes, specific problems follow. Testing becomes costly because verifying a business rule requires setting up repositories, factories, and transaction managers that have nothing to do with the rule itself. The domain model becomes anemic as entities degrade into data containers and behaviour disappears from the model. Business rules get duplicated across application services, with no single home for the logic—so when a rule changes, every copy must be found and updated. And changes to business policy require searching through application services rather than navigating directly to the relevant domain class.

The separation is the mechanism by which DDD's central promise—a domain model that speaks the language of the business—is kept.

Anti-Patterns That Undermine the Application Layer

Several failure modes appear repeatedly in codebases that start with good intentions.

The bloated application service is the most common. The service begins with simple orchestration, but over time business logic accumulates inside it: conditional statements, state calculations, validations. Eventually the domain model becomes a passive data structure and the application service contains all the interesting code. The remedy is pushing logic down relentlessly. Every time a conditional appears in an application service, the right question is whether it reflects a business rule—and if it does, it belongs in an entity, value object, or domain service.

Transactions at the wrong boundary create subtle and hard-to-diagnose failures. When transactions open in controllers, commit in repositories, or span helper methods called from multiple services, partial commits occur, nested transactions produce unexpected semantics, and the system can reach inconsistent states. One use case, one transaction, beginning and ending in the application layer.

The god application service—a single class with twenty methods, fifteen dependencies, and unrelated use cases from different parts of the domain—grows because it is the path of least resistance. Application services should be focused, grouped by aggregate or closely related use case cluster. A service handling PlaceOrder, CancelOrder, and UpdateShippingAddress is coherent. Adding ProcessRefund and NotifyWarehouse to that same class is a signal to split.

Validation mixed in the wrong place conflates infrastructure concerns with domain policy. Structural validation—required fields, type correctness, format constraints—belongs in the command object. Business validation—is the customer eligible, is the amount within permitted limits—belongs in the domain. Keeping them separate keeps responsibility clear.

Testing an Application Layer That Has No Business Logic

Because application services contain no business logic, their tests focus exclusively on orchestration: did the right aggregates get loaded, were the right domain methods called, was the result saved, was the correct DTO returned? Dependencies are replaced with mocks or fakes:

class PlaceOrderApplicationServiceTest {

    private CustomerRepository customerRepository;
    private OrderRepository orderRepository;
    private OrderFactory orderFactory;
    private DomainEventPublisher eventPublisher;
    private PlaceOrderApplicationService service;

    @BeforeEach
    void setUp() {
        customerRepository = mock(CustomerRepository.class);
        orderRepository = mock(OrderRepository.class);
        orderFactory = mock(OrderFactory.class);
        eventPublisher = mock(DomainEventPublisher.class);
        service = new PlaceOrderApplicationService(
            customerRepository, orderRepository, orderFactory, eventPublisher
        );
    }

    @Test
    void shouldSaveOrderWhenCommandIsValid() {
        var customerId = new CustomerId(UUID.randomUUID());
        var customer = CustomerFixtures.aValidCustomer(customerId);
        var order = OrderFixtures.aDraftOrder();
        var command = new PlaceOrderCommand(customerId, List.of(someItem()), someAddress());

        when(customerRepository.findById(customerId)).thenReturn(Optional.of(customer));
        when(orderFactory.create(customer, command.items(), command.shippingAddress()))
            .thenReturn(order);

        var response = service.execute(command);

        verify(orderRepository).save(order);
        assertThat(response.orderId()).isEqualTo(order.getId().value());
    }

    @Test
    void shouldThrowWhenCustomerNotFound() {
        var customerId = new CustomerId(UUID.randomUUID());
        when(customerRepository.findById(customerId)).thenReturn(Optional.empty());

        var command = new PlaceOrderCommand(customerId, List.of(someItem()), someAddress());

        assertThatThrownBy(() -> service.execute(command))
            .isInstanceOf(CustomerNotFoundException.class);
    }
}

These tests run in milliseconds. No database, no transaction manager, no Spring context. They verify that the service coordinates correctly.

Domain logic, located in the domain layer, can be tested without any infrastructure:

class OrderTest {

    @Test
    void shouldTransitionToPlacedWhenDraftOrderIsPlaced() {
        Order order = OrderFixtures.aDraftOrderWithItems();
        order.place();
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PLACED);
    }

    @Test
    void shouldThrowWhenAttemptingToPlaceAlreadyPlacedOrder() {
        Order order = OrderFixtures.aPlacedOrder();
        assertThatThrownBy(order::place)
            .isInstanceOf(OrderNotPlaceableException.class);
    }

    @Test
    void shouldThrowWhenPlacingOrderWithNoItems() {
        Order order = OrderFixtures.anEmptyDraftOrder();
        assertThatThrownBy(order::place)
            .isInstanceOf(EmptyOrderException.class);
    }
}

Domain tests are fast, deterministic, and complete. They verify the business rules directly. A smaller set of integration tests, using an in-memory database or Testcontainers, verifies that transaction boundaries commit correctly and that aggregates round-trip through persistence accurately. Integration tests are more expensive to run and should be fewer in number. The majority of the test suite should be fast domain unit tests.

The Application Layer as a Deliberate Constraint

The application layer in a well-designed DDD system is thin by design—thin because everything interesting has been pushed elsewhere. Business logic is in the domain. Persistence details are in the infrastructure. External communication is in adapters. What remains in the application layer is pure procedure: the steps, in order, that make a use case happen.

This thinness is an achievement, not a limitation. When the application layer is genuinely thin, the domain model carries the weight of the business, and that model becomes the natural place to understand, discuss, and change business behaviour. It can be tested independently, understood by domain experts with minimal technical translation, and evolved without touching infrastructure.

The principles that make this possible are consistent: use cases drive structure; commands and queries separate state-changing from state-reading operations; DTOs shield the domain from external coupling; transactions align with use cases and begin and end in the application layer; and orchestration is held strictly apart from business logic.

The application layer, when designed with this discipline, becomes what it should be—a humble coordinator that lets the domain model speak clearly.

N

About N Sharma

Lead Architect at StackAndSystem

N Sharma is a technologist with over 28 years of experience in software engineering, system architecture, and technology consulting. He holds a Bachelor’s degree in Engineering, a DBF, and an MBA. His work focuses on research-driven technology education—explaining software architecture, system design, and development practices through structured tutorials designed to help engineers build reliable, scalable systems.

Disclaimer

This article is for educational purposes only. Assistance from AI-powered generative tools was taken to format and improve language flow. While we strive for accuracy, this content may contain errors or omissions and should be independently verified.

The Application Layer in Domain-Driven Design (Use Cases, DTOs & Trans...