Skip to main content

An Implementation Across Every Layer

Being told there are four layers still leaves it unclear how many classes an API that merely reads one table actually needs. Before reading the per-layer conventions, it helps to see how the classes for one feature line up.

Taking an API that retrieves order information by order ID, this page assembles the full set from DDL to endpoint. One table, one published operation: the smallest possible pass through every layer.

Where the Files Go​

src/main/java/jp/co/example/foo/
├── presentation/
│ ├── endpoint/
│ │ ├── OrderEndpointFactory.java
│ │ ├── OrderEndpoint.java
│ │ └── BusinessErrorException.java
│ ├── response/
│ │ └── OrderResponse.java
│ └── validator/
│ ├── GetOrderValidator.java
│ └── ValidationException.java
├── application/
│ ├── usecase/
│ │ └── GetOrderUseCase.java
│ └── exception/
│ └── OrderAppException.java
├── domain/
│ ├── model/
│ │ ├── Order.java
│ │ └── OrderStatus.java
│ ├── service/
│ │ ├── OrderService.java
│ │ └── OrderServiceFactory.java
│ ├── repository/
│ │ ├── OrderRepository.java
│ │ └── OrderRepositoryFactory.java
│ └── exception/
│ ├── OrderServiceException.java
│ ├── OrderRuntimeException.java
│ └── RepositoryException.java
└── infrastructure/
├── entity/
│ └── OrderEntity.java
├── dao/
│ └── OrderDAO.java
├── repository/
│ ├── StandardOrderRepository.java
│ └── StandardOrderRepositoryFactory.java
└── service/
├── StandardOrderService.java
└── StandardOrderServiceFactory.java

src/main/resources/META-INF/
├── im_web_api_maker/
│ └── packages
└── sql/jp/co/example/foo/infrastructure/dao/OrderDAO/
└── find-by-order-id.sql

src/main/storage/system/products/import/basic/foo/
└── foo-ddl_postgre.sql

SQL files go under src/main/resources/META-INF/sql: recreate the DAO's package path there, and place the files in a directory named after the DAO class beneath it. File names are written in kebab case. META-INF/im_web_api_maker/packages is the file that registers the endpoint with Web API Maker. DDL file names carry a suffix for each database product (_postgre / _oracle / _sqlserver). Only the PostgreSQL file is shown here, but the files for Oracle and SQLServer are prepared under the same naming rule.

The Table Definition​

CREATE TABLE foo_order (
order_id VARCHAR(15) NOT NULL,
customer_name VARCHAR(200) NOT NULL,
amount DECIMAL(15, 2) NOT NULL,
status VARCHAR(20) NOT NULL,
create_user_cd VARCHAR(100) NOT NULL,
create_date TIMESTAMP NOT NULL,
record_user_cd VARCHAR(100) NOT NULL,
record_date TIMESTAMP NOT NULL,
PRIMARY KEY (order_id)
);

The last four columns are the audit fields. insert and update on AbstractDAO set their values, so the application never assigns them.

The Entity​

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

import java.math.BigDecimal;
import java.sql.Timestamp;

import jp.co.intra_mart.mirage.annotation.Column;
import jp.co.intra_mart.mirage.annotation.PrimaryKey;
import jp.co.intra_mart.mirage.annotation.PrimaryKey.GenerationType;
import jp.co.intra_mart.mirage.annotation.Table;

/**
* Order information entity (foo_order).
*/
@Table(name = "foo_order")
public class OrderEntity {

@PrimaryKey(generationType = GenerationType.APPLICATION)
@Column(name = "order_id")
public String orderId;

@Column(name = "customer_name")
public String customerName;

@Column(name = "amount")
public BigDecimal amount;

@Column(name = "status")
public String status;

@Column(name = "create_user_cd")
public String createUserCd;

@Column(name = "create_date")
public Timestamp createDate;

@Column(name = "record_user_cd")
public String recordUserCd;

@Column(name = "record_date")
public Timestamp recordDate;
}

The DAO and Its SQL​

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

import jp.co.example.foo.infrastructure.entity.OrderEntity;
import jp.co.intra_mart.mirage.ext.dao.AbstractDAO;

/**
* DAO that operates on the order information table (foo_order).
*/
public class OrderDAO extends AbstractDAO<OrderEntity> {

/** SQL file path (relative to the classpath root, with no leading slash) */
private static final String SQL_PATH = "META-INF/sql/jp/co/example/foo/infrastructure/dao/OrderDAO/";

/**
* Retrieves order information for the given order ID.
* @param orderId the order ID
* @return the order information, or null when there is no match
*/
public OrderEntity findByOrderId(final String orderId) {
final FindByIdCriteria criteria = new FindByIdCriteria();
criteria.orderId = orderId;
return sqlManager.getSingleResult(
OrderEntity.class, SQL_PATH.concat("find-by-order-id.sql"), criteria);
}

/**
* Holds the search criteria.
*/
public static class FindByIdCriteria {
public String orderId;
}
}

find-by-order-id.sql:

SELECT
order_id,
customer_name,
amount,
status,
create_user_cd,
create_date,
record_user_cd,
record_date
FROM
foo_order
WHERE
order_id = /*orderId*/'ORDER001'

The Domain Model​

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;
}
}

Where the entity held status as a String, the domain model holds it as the OrderStatus enum. The conversion from string to enum happens in the repository implementation class, so only interpreted values reach the domain layer.

The Repository​

The interface belongs to the domain layer.

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

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;

void save(Order order) throws RepositoryException;
}

The implementation belongs to the infrastructure layer. The conversion between entities and domain models is completed inside this class.

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;
}
}

The repository wraps its DAO calls in SessionTemplate, reads included. When it is called from a service, it merges into the outer boundary the service has established.

save does not pass a newly assembled entity straight to update. This is because update writes every column except the primary key, overwriting any field whose value has not been set with null. It applies only the changes to the existing entity retrieved with find, and then passes that entity to update.

The Service​

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

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

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

Order findByOrderId(String orderId) throws OrderServiceException;
}
package jp.co.example.foo.infrastructure.service;

import jp.co.example.foo.domain.exception.OrderServiceException;
import jp.co.example.foo.domain.exception.RepositoryException;
import jp.co.example.foo.domain.model.Order;
import jp.co.example.foo.domain.repository.OrderRepository;
import jp.co.example.foo.domain.repository.OrderRepositoryFactory;
import jp.co.example.foo.domain.service.OrderService;
import jp.co.intra_mart.common.platform.log.Logger;
import jp.co.intra_mart.mirage.ext.session.SessionTemplate;

/**
* The standard implementation of {@link OrderService}.
*/
public class StandardOrderService implements OrderService {

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

private final OrderRepository orderRepository;

public StandardOrderService() {
try {
this.orderRepository = OrderRepositoryFactory.getInstance().getOrderRepository();
} catch (final RepositoryException e) {
throw new RuntimeException("OrderRepository の初期化に失敗しました", e);
}
}

StandardOrderService(final OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}

@Override
public Order findByOrderId(final String orderId) throws OrderServiceException {
if (orderId == null || orderId.isEmpty()) {
throw new OrderServiceException("orderId は必須です");
}
return SessionTemplate.execute(s -> {
try {
return orderRepository.findByOrderId(orderId);
} catch (final RepositoryException e) {
LOGGER.error("Failed to find order: orderId=" + orderId, e);
throw new OrderServiceException("発注情報の検索に失敗しました: orderId=" + orderId, e);
}
});
}
}

The service also establishes a boundary with SessionTemplate, read-only operations included. The repository's boundary becomes nested and merges into the service's boundary, so the unit of commit is decided by the service.

A callback can throw only one kind of checked exception. For that reason, the repository's RepositoryException is replaced with an OrderServiceException inside the callback. Done this way, there is no need to wrap SessionTemplate.execute in an outer try.

Why the argument validation is placed outside SessionTemplate.execute, and how to write update operations, are covered in Infrastructure Layer Implementation Rules; the behavior when boundaries are nested is covered in Transaction Control.

The factory is an abstract class that goes in the same package as the interface, and a factory that returns the default implementation goes in the infrastructure layer.

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

import jp.co.example.foo.domain.exception.OrderRuntimeException;
import jp.co.example.foo.domain.exception.OrderServiceException;
import jp.co.example.foo.infrastructure.service.StandardOrderServiceFactory;
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 OrderService}.
*/
public abstract class OrderServiceFactory {

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

private static final class LazyHolder {
private static final OrderServiceFactory INSTANCE = loadOrderServiceFactory();

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

protected OrderServiceFactory() {
super();
}

/**
* Obtains an instance of {@link OrderService}.
* @return the service instance
* @throws OrderServiceException if the instance cannot be obtained
*/
public abstract OrderService getOrderService() throws OrderServiceException;

/**
* Obtains the factory instance.
* @return the factory
*/
public static OrderServiceFactory getInstance() {
return LazyHolder.INSTANCE;
}
}
package jp.co.example.foo.infrastructure.service;

import jp.co.example.foo.domain.service.OrderService;
import jp.co.example.foo.domain.service.OrderServiceFactory;

/**
* The standard implementation of {@link OrderServiceFactory}.
*/
public class StandardOrderServiceFactory extends OrderServiceFactory {

@Override
public OrderService getOrderService() {
return new StandardOrderService();
}
}

The repository factories (OrderRepositoryFactory and StandardOrderRepositoryFactory) take the same form, with getOrderRepository() throwing RepositoryException.

The Use Case and the Endpoint​

The use case converts the domain model into a response DTO and replaces the domain exception with an application exception.

package jp.co.example.foo.application.usecase;

import jp.co.example.foo.application.exception.OrderAppException;
import jp.co.example.foo.domain.exception.OrderServiceException;
import jp.co.example.foo.domain.model.Order;
import jp.co.example.foo.domain.service.OrderService;
import jp.co.example.foo.domain.service.OrderServiceFactory;
import jp.co.example.foo.presentation.response.OrderResponse;
import jp.co.intra_mart.common.platform.log.Logger;

/**
* Use case that retrieves order information.
*/
public class GetOrderUseCase {

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

private final OrderService orderService;

public GetOrderUseCase() {
try {
this.orderService = OrderServiceFactory.getInstance().getOrderService();
} catch (final OrderServiceException e) {
throw new RuntimeException("OrderService の初期化に失敗しました", e);
}
}

public OrderResponse execute(final String orderId) throws OrderAppException {
try {
final Order order = orderService.findByOrderId(orderId);
return order != null ? OrderResponse.fromDomainModel(order) : null;
} catch (final OrderServiceException e) {
LOGGER.error("Failed to get order: orderId=" + orderId, e);
throw new OrderAppException("発注情報の取得に失敗しました", e);
}
}
}

When there is no matching order, it returns null. If you want to report this to the client as a 404, throw an exception annotated with @Response(code = 404).

The endpoint validates the input and calls the use case. It replaces an application exception with a presentation layer exception that specifies a status code, and throws that instead.

package jp.co.example.foo.presentation.endpoint;

import java.util.List;

import jp.co.example.foo.application.exception.OrderAppException;
import jp.co.example.foo.application.usecase.GetOrderUseCase;
import jp.co.example.foo.presentation.response.OrderResponse;
import jp.co.example.foo.presentation.validator.GetOrderValidator;
import jp.co.example.foo.presentation.validator.ValidationException;
import jp.co.intra_mart.foundation.authz.annotation.Authz;
import jp.co.intra_mart.foundation.web_api_maker.annotation.GET;
import jp.co.intra_mart.foundation.web_api_maker.annotation.IMAuthentication;
import jp.co.intra_mart.foundation.web_api_maker.annotation.Path;
import jp.co.intra_mart.foundation.web_api_maker.annotation.Required;
import jp.co.intra_mart.foundation.web_api_maker.annotation.Variable;

/**
* REST API endpoint that provides order information.
*/
@IMAuthentication
@Authz(uri = "service://foo/web/tenant", action = "execute")
public class OrderEndpoint {

private final GetOrderUseCase useCase = new GetOrderUseCase();

@Path("/api/foo/order/{orderId}")
@GET(summary = "発注取得", description = "発注IDを指定して発注情報を取得します。")
public OrderResponse get(
@Required @Variable(name = "orderId", description = "発注ID") final String orderId)
throws BusinessErrorException {

final List<String> errors = GetOrderValidator.validate(orderId);
if (!errors.isEmpty()) {
throw new ValidationException(errors);
}
try {
return useCase.execute(orderId);
} catch (final OrderAppException e) {
throw new BusinessErrorException(e.getMessage(), e);
}
}
}

The factory class and registration file that publish the endpoint, the response DTO, the validator, and the exception classes are shown in Presentation Layer Implementation Rules.

The Path of a Call​

GET /api/foo/order/ORDER001
↓
(Web API Maker) … Authentication and authorization (before the endpoint is called)
↓
OrderEndpoint#get … Input validation, exception replacement
↓
GetOrderUseCase#execute … Exception translation, assembling the response DTO
↓
StandardOrderService#findByOrderId … Deciding whether the operation holds up as business, transaction boundary
↓
StandardOrderRepository#findByOrderId … Converting the entity into a domain model, transaction boundary (merges into the outer one)
↓
OrderDAO#findByOrderId … Running OrderDAO/find-by-order-id.sql
↓
The foo_order table

Web API Maker wraps the response in an object with error and data before returning it to the client. The client takes the order information out of data. This is covered in detail in Presentation Layer Implementation Rules.

Counting the classes alone, this looks like a lot of implementation for retrieving a single record. Where the structure starts paying off is when you add the second API. The service and the repository are reused as they are, and all you add is a use case and a response DTO.