Skip to main content

Domain Layer Implementation Rules

What lives in the domain layer are the domain models that express the business itself, and the repository and service interfaces. No implementation classes are here.

PackageClassRole
domain.modelOrder, OrderStatusDomain models carrying business rules
domain.repositoryOrderRepository, OrderRepositoryFactoryThe persistence interface and where to obtain it
domain.serviceOrderService, OrderServiceFactoryThe business logic interface and where to obtain it
domain.exceptionOrderServiceException, RepositoryException, OrderRuntimeExceptionThe exceptions this layer throws

Dependencies This Layer Does Not Have​

The domain layer knows nothing of how database access works. Neither SessionTemplate nor DAOFactory appears in this layer, or in its import statements.

References to the presentation layer are equally absent. Growing a toResponse() on a domain model ties a business type to the needs of an API.

The one way out of this rule is the factory. In order to return the default implementation, a factory references the Standard factory in the infrastructure layer. Gathering that into one place leaves no such reference in any other class.

Domain Models​

A domain model is an immutable class that carries business rules.

package jp.co.example.foo.domain.model;

import java.math.BigDecimal;

/**
* Domain model representing an order.
*/
public class Order {

private final String orderId;

private final String customerName;

private final BigDecimal amount;

private final OrderStatus status;

public Order(final String orderId, final String customerName,
final BigDecimal amount, final OrderStatus status) {
this.orderId = orderId;
this.customerName = customerName;
this.amount = amount;
this.status = status;
}

public String getOrderId() {
return orderId;
}

public String getCustomerName() {
return customerName;
}

public BigDecimal getAmount() {
return amount;
}

public OrderStatus getStatus() {
return status;
}

/**
* Determines whether the order is in an editable state.
* @return true when it can be edited
*/
public boolean isEditable() {
return status == OrderStatus.DRAFT;
}
}

Fields are private and only getters are exposed. Since there are no setters, changing a value means constructing a new instance.

Where an entity holds status as a String, the domain model holds it as the OrderStatus enum. The conversion from string to enum is done by the repository implementation, so only interpreted values reach this layer.

The table comparing entities and domain models is in Overall Architecture.

Business Rules: Model or Service​

Decide by what the rule needs in order to reach a verdict.

  • A rule settled by the model's own state goes on the model: isEditable() can decide by looking at its own status.
  • A rule that compares several models or external state goes on the service: comparing stock on hand against an order quantity needs a value pulled from another repository, which makes it the service's job.

Putting a model-decidable rule on the service duplicates the same check across several services. Conversely, forcing a rule that needs a repository onto the model makes the model reference a repository, which puts it out of reach of a unit test.

The Repository Interface​

The interface lists only the persistence operations. Its parameters and return values are domain models.

package jp.co.example.foo.domain.repository;

import java.util.List;

import jp.co.example.foo.domain.exception.RepositoryException;
import jp.co.example.foo.domain.model.Order;

/**
* Repository interface responsible for persisting order information.
*/
public interface OrderRepository {

Order findByOrderId(String orderId) throws RepositoryException;

List<Order> findByStatus(String status) throws RepositoryException;

void save(Order order) throws RepositoryException;
}

OrderEntity cannot be used here. An entity is a class in the infrastructure layer, and letting it appear in this layer's interfaces turns the dependency outward.

Write methods take a single domain model at a time. When you want to handle several records, loop in the service or use a batch method on the DAO. Lookups may return every matching record as a list.

Method names follow the same discipline throughout: findBy{Criteria} and findAll for lookups (findAll{EntityName}s when the name includes the target), findLatest{EntityName} for the most recent single record, save for writes, remove for deletes.

The Service Interface​

A service is the entry point for the business logic that use cases call.

package jp.co.example.foo.domain.service;

import java.util.List;

import jp.co.example.foo.domain.exception.OrderServiceException;
import jp.co.example.foo.domain.model.Order;
import jp.co.example.foo.domain.model.OrderItem;

/**
* Service interface providing the business logic for order information.
*/
public interface OrderService {

void register(Order order, List<OrderItem> items) throws OrderServiceException;

List<Order> findByStatus(String status) throws OrderServiceException;
}

Names take the OrderService shape, with no I prefix. Business operations are named process{Operation} or calculate{Metric}, and checks validate{Rule} or is{State}.

The exceptions thrown are uniformly {DomainName}ServiceException. Letting the RepositoryException from a repository pass straight through leaves the caller unable to tell a persistence failure from a business failure.

Factories​

Where instances are obtained is gathered into a factory. The factory is an abstract class and goes in the same package as the interface.

package jp.co.example.foo.domain.repository;

import jp.co.example.foo.domain.exception.OrderRuntimeException;
import jp.co.example.foo.domain.exception.RepositoryException;
import jp.co.example.foo.infrastructure.repository.StandardOrderRepositoryFactory;
import jp.co.intra_mart.common.aid.jdk.java.util.ServiceLoaderUtil;
import jp.co.intra_mart.common.platform.log.Logger;

/**
* Factory class for obtaining {@link OrderRepository}.
*/
public abstract class OrderRepositoryFactory {

private static final Logger LOGGER = Logger.getLogger(OrderRepositoryFactory.class);

private static final class LazyHolder {
private static final OrderRepositoryFactory INSTANCE = loadOrderRepositoryFactory();

private static OrderRepositoryFactory loadOrderRepositoryFactory() {
try {
final OrderRepositoryFactory factory = ServiceLoaderUtil.loadFirst(OrderRepositoryFactory.class);
if (factory != null) {
return factory;
}
return new StandardOrderRepositoryFactory();
} catch (final Exception e) {
LOGGER.error("Failed to load OrderRepositoryFactory", e);
throw new OrderRuntimeException("Failed to load OrderRepositoryFactory", e);
}
}
}

protected OrderRepositoryFactory() {
super();
}

/**
* Obtains an instance of {@link OrderRepository}.
* @return the repository instance
* @throws RepositoryException when obtaining it fails
*/
public abstract OrderRepository getOrderRepository() throws RepositoryException;

/**
* Obtains the factory instance.
* @return the factory
*/
public static OrderRepositoryFactory getInstance() {
return LazyHolder.INSTANCE;
}
}

The factory that returns the default implementation goes in the infrastructure layer.

package jp.co.example.foo.infrastructure.repository;

import jp.co.example.foo.domain.repository.OrderRepository;
import jp.co.example.foo.domain.repository.OrderRepositoryFactory;

/**
* Standard implementation class of {@link OrderRepositoryFactory}.
*/
public class StandardOrderRepositoryFactory extends OrderRepositoryFactory {

@Override
public OrderRepository getOrderRepository() {
return new StandardOrderRepository();
}
}

The caller takes out the factory with getInstance() and receives the repository from getOrderRepository().

final OrderRepository orderRepository = OrderRepositoryFactory.getInstance().getOrderRepository();

The service factory takes the same shape. It is a pair of the abstract OrderServiceFactory and StandardOrderServiceFactory, which returns the default implementation, and the method that obtains the service is getOrderService().

ServiceLoaderUtil is a platform-wide utility, provided by the im_jdk_assist module. It is not an im_mirage API, so calling it from the domain layer does not run against this layer's constraint.

The swapping mechanism rides on the standard Java ServiceLoader. Writing the fully qualified name of a factory class that extends OrderRepositoryFactory on one line in a file named META-INF/services/jp.co.example.foo.domain.repository.OrderRepositoryFactory makes that factory discoverable. loadFirst returns the first of the factories it discovers, or null if none are registered. When none are registered, the default StandardOrderRepositoryFactory is used.

With this, another module can change the behavior simply by registering a factory, without touching the calling code.

When loading the factory fails, it throws the unchecked OrderRuntimeException. A failure to load means the system was already broken at startup, and the caller has no way to deal with it.

Exception Classes​

There are three exceptions in domain.exception. Of these, OrderRuntimeException is used when a factory fails to load.

// Checked. Business rule violations, invalid input, missing target data
public class OrderServiceException extends Exception {

public OrderServiceException(final String message) {
super(message);
}

public OrderServiceException(final String message, final Throwable cause) {
super(message, cause);
}
}
// Checked. A failure to persist
public class RepositoryException extends Exception {

public RepositoryException(final String message, final Throwable cause) {
super(message, cause);
}
}
// Unchecked. Implementation errors, such as a factory that fails to load
public class OrderRuntimeException extends RuntimeException {

public OrderRuntimeException(final String message, final Throwable cause) {
super(message, cause);
}
}

A failure the caller can do something about is a checked exception; one it cannot is unchecked. An order that cannot be found is a situation the caller can handle, whereas a factory that cannot load an implementation is a system that was already broken at startup.

Messages are written in Japanese and carry the variable values needed to identify the cause, because they reach operators and users. Log messages, on the other hand, are written in English.