メインコンテンツまでスキップ

アンチパターン集

レイヤ違反は、書いた時点では動きます。 テストも通ります。 問題が表に出るのは、実装を差し替えたくなったとき、同じ処理をジョブから呼びたくなったとき、テーブル定義を変えたときです。

ここに挙げるのは、レビューで繰り返し指摘される十の実装です。 どれも一行から二行の違いでしかなく、書いた本人には違いが見えにくいという共通点があります。

エンドポイントから DAO を直接呼ぶ​

// 避けたい実装
@Path("/api/foo/order/{orderId}")
@GET
public OrderEntity get(@Variable(name = "orderId") final String orderId) {
final OrderDAO dao = DAOFactory.getTenantDatabaseDAO(OrderDAO.class);
return dao.find(orderId);
}
// 望ましい実装
@Path("/api/foo/order/{orderId}")
@GET
public OrderResponse get(@Variable(name = "orderId") final String orderId) throws BusinessErrorException {
try {
return useCase.execute(orderId);
} catch (final OrderAppException e) {
throw new BusinessErrorException(e.getMessage(), e);
}
}

プレゼンテーション層からインフラストラクチャ層を直接参照すると、あいだの二層が持っていた業務ルールが適用されません。 サービスが行う業務としての妥当性の判定も、ドメインモデルへの変換も、経由しなければ効きません。

エンティティを API のレスポンスに返す​

// 避けたい実装
public OrderEntity findOrder(final String orderId) {
return dao.find(orderId);
}
// 望ましい実装
public OrderResponse findOrder(final String orderId) throws OrderServiceException {
final Order order = orderService.findByOrderId(orderId);
return OrderResponse.fromDomainModel(order);
}

エンティティはテーブルの形をそのまま写したクラスです。 外に出せば、内部のカラム名と監査項目がクライアントに見えます。 それだけでなく、テーブル定義を変えた瞬間に API の互換性が壊れます。

リポジトリに業務ルールを書く​

// 避けたい実装
public class StandardOrderRepository implements OrderRepository {

@Override
public void save(final Order order) throws RepositoryException {
if (order.getAmount().compareTo(BigDecimal.ZERO) < 0) {
throw new RepositoryException("金額が不正です");
}
// 永続化処理
}
}
// 望ましい実装
public class StandardOrderService implements OrderService {

@Override
public void save(final Order order) throws OrderServiceException {
if (order.getAmount().compareTo(BigDecimal.ZERO) < 0) {
throw new OrderServiceException("金額は0以上である必要があります: amount=" + order.getAmount());
}
orderRepository.save(order);
}
}

リポジトリが担うのはデータアクセスと、エンティティとドメインモデルの変換だけです。 業務ルールをここに書くと、リポジトリを差し替えたときにルールも一緒に消えます。

ドメイン層がインフラストラクチャ層を参照する​

// 避けたい実装
package jp.co.example.foo.domain.service;

import jp.co.intra_mart.mirage.ext.dao.DAOFactory;
import jp.co.intra_mart.mirage.ext.session.SessionTemplate;

public class StandardOrderService {

public Order findByOrderId(final String orderId) {
final OrderDAO dao = DAOFactory.getTenantDatabaseDAO(OrderDAO.class);
// ...
}
}
// 望ましい実装
package jp.co.example.foo.infrastructure.service;

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

public class StandardOrderService implements OrderService {

private final OrderRepository orderRepository;
// ...
}

レビューでは import 文だけを見れば済みます。 ドメイン層のクラスに jp.co.intra_mart.mirage.* が現れていれば、その時点で依存が外向きになっています。 実装クラスはインフラストラクチャ層に置き、ドメイン層にはインタフェースだけを残します。

例外を握り潰す​

// 避けたい実装
try {
orderRepository.save(order);
} catch (final RepositoryException e) {
// 何もしない
}
// 望ましい実装
try {
orderRepository.save(order);
} catch (final RepositoryException e) {
LOGGER.error("Failed to save order: orderId=" + order.getOrderId(), e);
throw new OrderServiceException("発注の保存に失敗しました", e);
}

握り潰すと、更新が一部だけ適用された状態のまま処理が続きます。 呼び出し側は成功したと解釈するため、データの不整合は次の処理か、翌日の集計で表に出ます。

トランザクション境界をエンドポイントに置く​

// 避けたい実装
@Path("/api/foo/order")
@POST
public void create(@Body final OrderRequest request) {
SessionTemplate.execute(new SessionCallback<Void, RuntimeException>() {
@Override
public Void execute(final Session session) {
orderService.register(request);
return null;
}
});
}
// 望ましい実装
@Override
public void register(final Order order) throws OrderServiceException {
SessionTemplate.execute(s -> {
try {
orderRepository.save(order);
} catch (final RepositoryException e) {
throw new OrderServiceException("発注の登録に失敗しました: orderId=" + order.getOrderId(), e);
}
return null;
});
}

境界を張るのは、サービスとリポジトリの実装です。 境界を外側に持ち出すと、SessionTemplate への依存がプレゼンテーション層まで広がります。 リポジトリをインタフェースで隠した意味がなくなり、同じ処理をジョブから呼ぶときに境界を書き直すことになります。

業務例外のメッセージを英語で書く​

// 避けたい実装
throw new OrderServiceException("Order not found: id=" + orderId);
// 望ましい実装
throw new OrderServiceException("発注が見つかりません: orderId=" + orderId);

書き分けの基準は、読む相手が誰かにあります。 業務例外のメッセージは運用の担当者や利用者の目に触れるため、日本語で書き、原因の特定に必要な変数値を添えます。 開発者しか読まないログメッセージは、英語のままでかまいません。

ファクトリを使わずに直接 new する​

// 避けたい実装
public class GetOrderUseCase {

private final OrderService orderService = new StandardOrderService();
}
// 望ましい実装
public class GetOrderUseCase {

private final OrderService orderService;

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

new で書くと、呼び出し側が既定の実装に固定されます。 ServiceLoaderUtil による差し替えも、テストでのモック注入も効かなくなります。

入力検証のエラーをログに出力する​

// 避けたい実装
final List<String> errors = GetOrderValidator.validate(orderId);
if (!errors.isEmpty()) {
LOGGER.error("Validation failed: " + errors);
throw new ValidationException(errors);
}
// 望ましい実装
final List<String> errors = GetOrderValidator.validate(orderId);
if (!errors.isEmpty()) {
throw new ValidationException(errors);
}

入力の不備は利用者の操作の結果であり、システムの障害ではありません。 ERROR として記録すると、対処すべき障害がログの中に埋もれます。 認可エラーも同じ理由で記録しません。

監査項目を手で設定する​

// 避けたい実装
entity.createUserCd = "admin";
entity.createDate = new Timestamp(System.currentTimeMillis());
entity.recordUserCd = "admin";
entity.recordDate = new Timestamp(System.currentTimeMillis());
dao.insert(entity);
// 望ましい実装
dao.insert(entity);

AbstractDAO の insert は監査項目の四フィールドを、update は更新者と更新日時の二フィールドを自動で設定します。 手で設定すると、ログインユーザとは無関係の値が記録され、更新履歴を追えなくなります。 監査項目の扱いはエンティティと DAO の作成で扱っています。

関連ドキュメント​