Infrastructure Layer Implementation Rules
This is the layer that actually makes the interfaces declared by the domain layer work. It queries tables, runs SQL, and decides the range of a transaction. The conventions of im_mirage may appear in code only inside this layer.
| Package | Class | Role |
|---|---|---|
infrastructure.entity | OrderEntity | One-to-one mapping to a table |
infrastructure.dao | OrderDAO | Table operations through im_mirage |
infrastructure.repository | StandardOrderRepository, StandardOrderRepositoryFactory | Implementation of the repository interface, and the default factory that returns it |
infrastructure.service | StandardOrderService, StandardOrderServiceFactory | Implementation of the service interface, and the default factory that returns it |
Implementation classes do not take a mechanical Impl; they take the Standard prefix, which marks them as the default implementation.
The standard features of the platform take this same shape.
How to write entities and DAOs is covered in Creating Entities and DAOs, the syntax of SQL files in 2WaySQL, and the behavior of SessionTemplate in Transaction Control.
This page covers the repository and service implementations that sit on top of them.
Implementing a Repository
A repository implementation calls the DAO and converts between entities and domain models.
package jp.co.example.foo.infrastructure.repository;
import jp.co.example.foo.domain.exception.RepositoryException;
import jp.co.example.foo.domain.model.Order;
import jp.co.example.foo.domain.model.OrderStatus;
import jp.co.example.foo.domain.repository.OrderRepository;
import jp.co.example.foo.infrastructure.dao.OrderDAO;
import jp.co.example.foo.infrastructure.entity.OrderEntity;
import jp.co.intra_mart.mirage.ext.dao.DAOFactory;
import jp.co.intra_mart.mirage.ext.session.SessionTemplate;
/**
* The standard implementation of {@link OrderRepository}.
*/
public class StandardOrderRepository implements OrderRepository {
@Override
public Order findByOrderId(final String orderId) throws RepositoryException {
try {
return SessionTemplate.execute(s -> {
final OrderDAO dao = DAOFactory.getTenantDatabaseDAO(OrderDAO.class);
final OrderEntity entity = dao.findByOrderId(orderId);
return entity != null ? convertToModel(entity) : null;
});
} catch (final SQLRuntimeException e) {
throw new RepositoryException("発注情報の検索に失敗しました: orderId=" + orderId, e);
}
}
@Override
public void save(final Order order) throws RepositoryException {
try {
SessionTemplate.execute(s -> {
final OrderDAO dao = DAOFactory.getTenantDatabaseDAO(OrderDAO.class);
final OrderEntity existing = dao.find(order.getOrderId());
if (existing != null) {
// update writes every column except the primary key, so apply only the changes to the existing entity
applyChanges(existing, order);
dao.update(existing);
} else {
dao.insert(convertToEntity(order));
}
return null;
});
} catch (final SQLRuntimeException e) {
throw new RepositoryException("発注情報の保存に失敗しました: orderId=" + order.getOrderId(), e);
}
}
private Order convertToModel(final OrderEntity entity) {
return new Order(entity.orderId, entity.customerName, entity.amount,
OrderStatus.valueOf(entity.status));
}
private void applyChanges(final OrderEntity entity, final Order order) {
entity.customerName = order.getCustomerName();
entity.amount = order.getAmount();
entity.status = order.getStatus().name();
}
private OrderEntity convertToEntity(final Order order) {
final OrderEntity entity = new OrderEntity();
entity.orderId = order.getOrderId();
applyChanges(entity, order);
return entity;
}
}
Every DAO call, reads included, is wrapped in SessionTemplate.
When it is called inside a service's boundary, it merges into that boundary.
When save updates a row, do not pass update an entity newly built from the domain model.
update writes every column except the primary key, so any field left unset is overwritten with null.
Apply the changes to the existing entity obtained with find, then pass that.
This is covered in detail in Creating Entities and DAOs.
The conversion methods are private. Exposing them opens a route for entities to be carried outside this layer.
The DAO obtained from DAOFactory is fetched again in every method.
Holding it in a field means reusing an instance across sessions.
A repository is responsible for data access and conversion only. Writing a business rule such as "the amount must not be negative" here means the rule disappears along with the repository the day it is swapped out.
Implementing a Service
A service implementation runs through validation, the transaction, the business rules, and finally the result.
@Override
public OrderResult processRegistration(final OrderInput input) throws OrderServiceException {
validateInput(input);
return SessionTemplate.execute(s -> {
final Order order;
try {
order = orderRepository.findByOrderId(input.getOrderId());
} catch (final RepositoryException e) {
LOGGER.error("Failed to find order: orderId=" + input.getOrderId(), e);
throw new OrderServiceException("発注の検索に失敗しました: orderId=" + input.getOrderId(), e);
}
if (order == null) {
throw new OrderServiceException("発注が見つかりません: orderId=" + input.getOrderId());
}
final Order processed = applyBusinessRules(order, input);
try {
orderRepository.save(processed);
} catch (final RepositoryException e) {
LOGGER.error("Failed to save order: orderId=" + input.getOrderId(), e);
throw new OrderServiceException("発注の登録に失敗しました: orderId=" + input.getOrderId(), e);
}
return buildResult(processed);
});
}
The callback can throw only one kind of checked exception.
If it threw both OrderServiceException for a business rule violation and RepositoryException for a persistence failure as they are, the caller could catch neither of them.
So RepositoryException is replaced with OrderServiceException inside the callback, right after the repository call.
validateInput sitting outside SessionTemplate.execute is not a slip.
A malformed input can be rejected without looking at the database.
Rejecting it after the connection has been obtained ties up a connection for work that was always going to fail.
There are two kinds of decision, and they belong in different places.
validateInput rejects the problems that can be settled by looking at the arguments alone.
private void validateInput(final OrderInput input) throws OrderServiceException {
if (input == null) {
throw new OrderServiceException("入力が null です");
}
if (input.getOrderId() == null || input.getOrderId().isEmpty()) {
throw new OrderServiceException("orderId は必須です");
}
if (input.getAmount() != null && input.getAmount().compareTo(BigDecimal.ZERO) < 0) {
throw new OrderServiceException("金額は0以上である必要があります: amount=" + input.getAmount());
}
}
applyBusinessRules decides by comparing the input against the current state read from the database.
Because it needs the current state, it is called inside the transaction.
private Order applyBusinessRules(final Order order, final OrderInput input) throws OrderServiceException {
if (!order.isEditable()) {
throw new OrderServiceException("編集できない状態です: status=" + order.getStatus());
}
return order.updateFrom(input);
}
This may look like it overlaps with the Validator in the presentation layer.
What a Validator looks at is the shape of the request: whether a string that arrived from outside can be read as a number, and whether a required field is missing.
What a service looks at is whether the operation holds up as business.
A request in perfectly good shape still does not hold up if the amount is negative.
A service can also be called from a batch job that never passes through the presentation layer, so the validation on the service side cannot be dropped.
Dependency Injection for Tests
The implementation class receives its repository from the factory in the default constructor. Alongside it, provide a constructor that takes the repository as a parameter.
public class StandardOrderService implements OrderService {
private final OrderRepository orderRepository;
public StandardOrderService() {
try {
this.orderRepository = OrderRepositoryFactory.getInstance().getOrderRepository();
} catch (final RepositoryException e) {
throw new RuntimeException("OrderRepository の初期化に失敗しました", e);
}
}
/**
* Constructor that swaps in a repository, for use from tests.
* @param orderRepository the order repository
*/
StandardOrderService(final OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}
Make the parameterized constructor package private. Production code can then obtain the service only through the factory, while a test placed in the same package can pass in a mock.
Establishing the Transaction Boundary
SessionTemplate.execute works wherever you write it.
That is exactly why failing to decide which layer establishes the boundary leaves two styles mixed together in the same module.
The boundary is established in both the service implementation and the repository implementation. The service is what decides the unit of commit.
public class StandardOrderService implements OrderService {
private static final Logger LOGGER = Logger.getLogger(StandardOrderService.class);
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository;
// Constructors omitted
@Override
public void register(final Order order, final List<OrderItem> items) throws OrderServiceException {
validateInput(order, items);
SessionTemplate.execute(s -> {
try {
orderRepository.save(order);
for (final OrderItem item : items) {
orderItemRepository.save(item);
}
} catch (final RepositoryException e) {
LOGGER.error("Failed to register order: orderId=" + order.getOrderId(), e);
throw new OrderServiceException("発注の登録に失敗しました: orderId=" + order.getOrderId(), e);
}
return null;
});
}
}
An order header and its line items cannot be left in a state where only one of them was written. The only layer that can combine two repositories into a single unit is the service that calls them both.
Each repository's save also establishes its own boundary with SessionTemplate, but that does not break the unit of update.
SessionTemplate detects the nesting and merges into the outer boundary, and commit or rollback is decided when the outer boundary ends.
That behavior is covered in Transaction Control.
When a repository is called on its own, without going through a service, the repository's boundary becomes the transaction as it is.
Translating Exceptions
This layer replaces the exceptions thrown by im_mirage with the vocabulary of the domain layer before passing them upward.
} catch (final SQLRuntimeException e) {
throw new RepositoryException("発注情報の保存に失敗しました: orderId=" + order.getOrderId(), e);
}
try {
orderRepository.save(processed);
} catch (final RepositoryException e) {
LOGGER.error("Failed to save order: orderId=" + input.getOrderId(), e);
throw new OrderServiceException("発注の登録に失敗しました: orderId=" + input.getOrderId(), e);
}
There are three rules.
- Preserve the
cause: pass the original exception as the second argument. Dropping it erases the SQL failure from the stack trace. - Do not catch unexpected
RuntimeExceptions: an implementation error such as aNullPointerException, wrapped in a business exception, ends up looking like a business failure. Let it pass through. - Do not swallow: catching and doing nothing leaves a partially applied update undetected while processing continues.
The exception classes themselves are defined in Domain Layer Implementation Rules.
Related Documentation
- Creating Entities and DAOs: Mapping with
@Table/@Column/@PrimaryKey, the supported column types, and the audit fields that are set automatically - 2WaySQL: Writing dynamic queries as SQL files
- Transaction Control: How
SessionTemplatebehaves, and nested transactions - Domain Layer Implementation Rules: The interfaces and exception classes implemented here
- An Implementation Across Every Layer: The full set from endpoint down to the database
- Anti-Patterns: Typical layer violations and how to fix them