Keeping Business Logic in Services

Put each business operation in one transactional service method, push rules that guard an entity's own state onto the entity, and keep HTTP and SQL details out.

Syntax@Transactional public Result operation(Command cmd) { var entity = repo.findById(...).orElseThrow(); entity.doSomething(clock.instant()); events.publishEvent(new SomethingHappened(...)); return Result.from(entity); }

Business logic is the part of the code that would still exist if the API were replaced by a command-line tool: what a valid order is, when a booking can be cancelled, how a discount is calculated. It belongs in the service layer, organised so each rule lives in exactly one place.

One method per operation

Name service methods after what the business calls the operation: cancelBooking, approveRefund, renewSubscription. Avoid a generic update(dto) that copies fields, because it hides which changes are allowed and in which states. Each operation method:

  1. loads what it needs through repositories,
  2. checks the rules and throws a domain exception when one is broken,
  3. changes state,
  4. publishes an event or calls another service when something else must react,
  5. returns a DTO or nothing.

Mark it @Transactional so all of that commits or rolls back together.

Services coordinate, entities guard themselves

A rule that depends only on an entity's own fields, such as "a cancelled booking cannot be cancelled again", is best written as a method on the entity: booking.cancel(now). Then no caller can put the booking in an invalid state with setters. Rules that need other data (another aggregate, a remote price, the current user) stay in the service. This keeps services short and stops the same check being copied into three of them.

Keep these out of services

  • HTTP types. No ResponseEntity, HttpStatus or request objects. Throw a domain exception such as BookingNotCancellableException and let a @RestControllerAdvice map it to 409 Conflict.
  • Hidden time. Instant.now() scattered through services makes rules hard to test. Inject a Clock.
  • Slow remote calls inside the transaction. They hold a database connection and row locks for the whole call. Fetch remote data first, or react after commit with an @Async @TransactionalEventListener. Without @Async the listener runs on the request thread before the caller gets its response, with the transaction's connection still bound.

Signs a service has grown too big

A service with thirty methods and twelve constructor parameters is several features in one class. Split by operation family (BookingCommands, BookingQueries) or by sub-feature. Calling one public @Transactional method from another in the same class is also a smell: the inner call skips the proxy, so its own transaction settings are ignored.

Validation is shared work: Bean Validation on the request DTO checks shape (not blank, positive, a valid email), while the service checks rules that need data (the room is free, the customer is not blocked). Do not try to do the second kind with annotations on the DTO.

Example

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

enum BookingStatus { CONFIRMED, CANCELLED }

class BookingNotCancellableException extends RuntimeException {
    BookingNotCancellableException(String reason) {
        super(reason);
    }
}

class BookingNotFoundException extends RuntimeException {
    BookingNotFoundException(long id) {
        super("Booking " + id + " not found");
    }
}

@Entity
class Booking {
    @Id
    private Long id;
    private String customerId;
    private Instant startsAt;
    @Enumerated(EnumType.STRING)
    private BookingStatus status;
    private Instant cancelledAt;

    protected Booking() {
    }

    // The entity guards its own state: no setStatus() exists.
    void cancel(Instant now, Duration noticeRequired) {
        if (status == BookingStatus.CANCELLED) {
            throw new BookingNotCancellableException("Booking is already cancelled");
        }
        if (now.plus(noticeRequired).isAfter(startsAt)) {
            throw new BookingNotCancellableException("Too late to cancel this booking");
        }
        status = BookingStatus.CANCELLED;
        cancelledAt = now;
    }

    Long getId() { return id; }
    String getCustomerId() { return customerId; }
}

interface BookingRepository extends JpaRepository<Booking, Long> {
}

record BookingCancelled(long bookingId, String customerId, Instant at) {
}

@Service
public class BookingCommands {

    private static final Duration NOTICE = Duration.ofHours(24);

    private final BookingRepository bookings;
    private final ApplicationEventPublisher events;
    private final Clock clock;

    BookingCommands(BookingRepository bookings, ApplicationEventPublisher events, Clock clock) {
        this.bookings = bookings;
        this.events = events;
        this.clock = clock;
    }

    @Transactional
    public void cancelBooking(long bookingId) {
        Booking booking = bookings.findById(bookingId)
                .orElseThrow(() -> new BookingNotFoundException(bookingId));
        Instant now = clock.instant();
        booking.cancel(now, NOTICE);
        // Email and refund listeners react after commit on another thread (@Async),
        // so the caller does not wait for them.
        events.publishEvent(new BookingCancelled(booking.getId(), booking.getCustomerId(), now));
    }
}

When to use it

  • A booking API moves its 24-hour cancellation rule from two controllers and a batch job into Booking.cancel, so all three paths enforce it.
  • A refund call to a payment provider is moved out of the cancel transaction into an after-commit listener to stop connection pool exhaustion.
  • A 40-method CustomerService is split into CustomerCommands and CustomerQueries, each with a handful of focused dependencies.

More examples

Mapping domain exceptions to HTTP in one place

The service throws exceptions that describe the business problem and knows nothing about status codes. This advice turns them into RFC 9457 problem responses. When the same <code>cancelBooking</code> method is called from a message listener, the exception is handled there in its own way, and no HTTP concept leaks into the listener.

Example · java
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

class BookingNotCancellableException extends RuntimeException {
    BookingNotCancellableException(String reason) {
        super(reason);
    }
}

class BookingNotFoundException extends RuntimeException {
    BookingNotFoundException(long id) {
        super("Booking " + id + " not found");
    }
}

@RestControllerAdvice
public class BookingErrors {

    @ExceptionHandler
    ProblemDetail notCancellable(BookingNotCancellableException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
    }

    @ExceptionHandler
    ProblemDetail notFound(BookingNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }
}

Remote work after commit, not inside the transaction

Calling the payment provider inside <code>cancelBooking</code> would keep the transaction open for the length of an HTTP call and could refund a booking whose cancellation then rolled back. Publishing an event and listening <code>AFTER_COMMIT</code> fixes both. The <code>@Async</code> matters: a synchronous after-commit listener still runs on the request thread while the committed transaction's resources are bound, so the response waits for the provider. The trade-off is that a crash between commit and refund loses the refund, so for money use a transactional outbox or a retrying job.

Example · java
import java.time.Instant;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

record BookingCancelled(long bookingId, String customerId, Instant at) {
}

interface PaymentGateway {
    void refundBooking(long bookingId);
}

@Component
public class RefundOnCancel {

    private final PaymentGateway payments;

    RefundOnCancel(PaymentGateway payments) {
        this.payments = payments;
    }

    // @Async needs @EnableAsync on a configuration class, or it is silently ignored.
    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void on(BookingCancelled event) {
        // Runs only if the cancellation committed, on another thread, so a slow
        // payment provider neither delays the response nor holds a database connection.
        payments.refundBooking(event.bookingId());
    }
}

Unit testing the rule with a fixed clock

Because the rule lives on the domain object and takes the current time as a parameter, the tests need no Spring context, no database and no mocking of static time. In the service the time comes from an injected <code>Clock</code>, and a service test can pass <code>Clock.fixed(...)</code> to get the same control.

Example · java
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.time.Duration;
import java.time.Instant;
import org.junit.jupiter.api.Test;

class Reservation {
    private final Instant startsAt;
    private boolean cancelled;

    Reservation(Instant startsAt) {
        this.startsAt = startsAt;
    }

    void cancel(Instant now, Duration notice) {
        if (cancelled) {
            throw new IllegalStateException("Already cancelled");
        }
        if (now.plus(notice).isAfter(startsAt)) {
            throw new IllegalStateException("Too late to cancel");
        }
        cancelled = true;
    }
}

class ReservationTest {

    private final Instant start = Instant.parse("2026-11-01T10:00:00Z");

    @Test
    void rejectsLateCancellation() {
        Reservation r = new Reservation(start);
        Instant twoHoursBefore = start.minus(Duration.ofHours(2));

        assertThatThrownBy(() -> r.cancel(twoHoursBefore, Duration.ofHours(24)))
                .hasMessage("Too late to cancel");
    }

    @Test
    void rejectsSecondCancellation() {
        Reservation r = new Reservation(start);
        Instant twoDaysBefore = start.minus(Duration.ofDays(2));
        r.cancel(twoDaysBefore, Duration.ofHours(24));

        assertThatThrownBy(() -> r.cancel(twoDaysBefore, Duration.ofHours(24)))
                .hasMessage("Already cancelled");
    }
}

Discussion

  • Be the first to comment on this lesson.