全レイヤ縦断の実装例
四つのレイヤに分けると言われても、テーブルを一つ引くだけの API に何クラス必要になるのかは、まだ見えてきません。 レイヤごとの規約を読む前に、一つの機能がどんなクラスの並びになるのかを見ておきます。
発注情報を発注IDで取得する API を例に、DDL からエンドポイントまでを一式そろえます。 テーブルは一つ、公開する操作も一つだけの、最小の縦断です。
ファイルの配置
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ファイルは、src/main/resources/META-INF/sql の下に DAO のパッケージパスを作り、その下の DAO のクラス名のディレクトリに置きます。
ファイル名はケバブケースで付けます。
META-INF/im_web_api_maker/packages は、エンドポイントを Web API Maker に登録するためのファイルです。
DDL のファイル名には、DB 製品ごとのサフィックス(_postgre / _oracle / _sqlserver)を付けます。
ここでは PostgreSQL 用だけを示しますが、Oracle と SQLServer 用のファイルも同じ名前の規則で用意します。
テーブル定義
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)
);
末尾の四カラムは監査項目です。
AbstractDAO の insert と update が値を設定するため、アプリケーション側で代入することはありません。
エンティティ
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;
/**
* 発注情報エンティティ(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;
}
DAO と 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;
/**
* 発注情報テーブル(foo_order)を操作する DAO です。
*/
public class OrderDAO extends AbstractDAO<OrderEntity> {
/** SQLファイルパス(クラスパス起点。先頭のスラッシュは付けない) */
private static final String SQL_PATH = "META-INF/sql/jp/co/example/foo/infrastructure/dao/OrderDAO/";
/**
* 発注IDを指定して発注情報を取得します。
* @param orderId 発注ID
* @return 発注情報。該当がない場合は null
*/
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);
}
/**
* 検索条件を保持するクラスです。
*/
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'
ドメインモデル
package jp.co.example.foo.domain.model;
import java.math.BigDecimal;
/**
* 発注を表すドメインモデルです。
*/
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;
}
/**
* 編集できる状態かどうかを判定します。
* @return 編集できる場合は true
*/
public boolean isEditable() {
return status == OrderStatus.DRAFT;
}
}
エンティティが status を String で保持していたのに対し、ドメインモデルは OrderStatus という列挙型で保持します。
文字列から列挙型への変換はリポジトリの実装クラスで行い、ドメイン層には解釈済みの値だけが届きます。
リポジトリ
インタフェースはドメイン層に置きます。
package jp.co.example.foo.domain.repository;
import jp.co.example.foo.domain.exception.RepositoryException;
import jp.co.example.foo.domain.model.Order;
/**
* 発注情報の永続化を担う Repository インタフェースです。
*/
public interface OrderRepository {
Order findByOrderId(String orderId) throws RepositoryException;
void save(Order order) throws RepositoryException;
}
実装はインフラストラクチャ層です。 エンティティとドメインモデルの変換は、このクラスの中で完結します。
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;
/**
* {@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 は主キー以外の全カラムを更新するため、既存のエンティティに変更点だけを反映する
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;
}
}
リポジトリは、参照も含めて DAO の呼び出しを SessionTemplate で囲みます。
サービスから呼ばれたときは、サービスが張った外側の境界に合流します。
save は、新しく組み立てたエンティティをそのまま update に渡しません。
update は主キー以外の全カラムを更新し、値を設定していないフィールドを null で上書きするためです。
find で取得した既存のエンティティに変更点だけを反映してから、update に渡します。
サービス
package jp.co.example.foo.domain.service;
import jp.co.example.foo.domain.exception.OrderServiceException;
import jp.co.example.foo.domain.model.Order;
/**
* 発注情報のビジネスロジックを提供する Service インタフェースです。
*/
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;
/**
* {@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);
}
});
}
}
サービスも、参照だけの処理を含めて SessionTemplate で境界を張ります。
リポジトリ側の境界は入れ子になり、サービスの境界に合流するため、コミットの単位はサービスで決まります。
コールバックが送出できる検査例外は一種類に限られます。
そのため、リポジトリの RepositoryException はコールバックの中で OrderServiceException に置き換えます。
こうしておくと、SessionTemplate.execute を外側の try で囲む必要がありません。
引数の検証を SessionTemplate.execute の外に置く理由と、更新処理での書き方はインフラストラクチャ層の実装ルール、入れ子になったときの挙動はトランザクション制御で扱っています。
ファクトリは抽象クラスとしてインタフェースと同じパッケージに置き、既定の実装を返すファクトリをインフラストラクチャ層に置きます。
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;
/**
* {@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();
}
/**
* {@link OrderService} のインスタンスを取得します。
* @return Service インスタンス
* @throws OrderServiceException 取得に失敗した場合
*/
public abstract OrderService getOrderService() throws OrderServiceException;
/**
* ファクトリのインスタンスを取得します。
* @return ファクトリ
*/
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;
/**
* {@link OrderServiceFactory} の標準実装クラスです。
*/
public class StandardOrderServiceFactory extends OrderServiceFactory {
@Override
public OrderService getOrderService() {
return new StandardOrderService();
}
}
リポジトリのファクトリ(OrderRepositoryFactory と StandardOrderRepositoryFactory)も同じ形で、getOrderRepository() が RepositoryException を送出します。
ユースケースとエンドポイント
ユースケースはドメインモデルをレスポンス DTO に変換し、ドメイン例外をアプリケーション例外に置き換えます。
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;
/**
* 発注情報を取得するユースケースです。
*/
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);
}
}
}
該当する発注がないときは null を返します。
クライアントに 404 として伝えたい場合は、@Response(code = 404) を付けた例外を投げます。
エンドポイントは入力を検証し、ユースケースを呼びます。 アプリケーション例外は、ステータスコードを指定したプレゼンテーション層の例外に置き換えてから投げます。
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 エンドポイントです。
*/
@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);
}
}
}
エンドポイントを公開するためのファクトリクラスと登録ファイル、レスポンス DTO、バリデータ、例外クラスの実装はプレゼンテーション層の実装ルールに掲載しています。
呼び出しの流れ
GET /api/foo/order/ORDER001
↓
(Web API Maker) … 認証と認可の判定(エンドポイントの呼び出し前)
↓
OrderEndpoint#get … 入力検証、例外の置き換え
↓
GetOrderUseCase#execute … 例外の変換、レスポンス DTO の組み立て
↓
StandardOrderService#findByOrderId … 業務としての妥当性の判定、トランザクション境界
↓
StandardOrderRepository#findByOrderId … エンティティからドメインモデルへの変換、トランザクション境界(外側に合流)
↓
OrderDAO#findByOrderId … OrderDAO/find-by-order-id.sql の実行
↓
foo_order テーブル
レスポンスは、Web API Maker が error と data を持つオブジェクトで包んでからクライアントに返します。
クライアントは data から発注情報を取り出します。
詳しくはプレゼンテーション層の実装ルールで扱います。
クラスの数だけを見ると、一件を取得するための実装としては多く映ります。 この構成が効いてくるのは、二つめの API を追加したときです。 サービスとリポジトリはそのまま再利用でき、追加するのはユースケースとレスポンス DTO だけになります。
関連ドキュメント
- 全体アーキテクチャ:レイヤの責務、パッケージ構成、命名
- エンティティと DAO の作成:マッピングの規約と監査項目
- 2WaySQL:SQLファイルの書き方
- インフラストラクチャ層の実装ルール:トランザクション境界と例外の変換
- アンチパターン集:この構成から外れたときに起きること