跳至主要内容

專案後端模式

本頁說明應用程式如何在 AppFuse Server 之上組織業務程式碼。範例以目前的 app-tenant-server 為準;它是可參考的專案模式,不是取代各功能 API Specification 的公開契約。

:::info 適用範圍

  • app-tenant-server 使用多租戶 Entity 與 TenantContext
  • app-tenantless-server 沒有租戶欄位與租戶切換,但分層、交易與權限原則相同。
  • 專案採用哪一種業務目錄配置,以該模組 .claude/features.jsonbusinessLayout 為準。

:::

目錄與責任

目前參考專案採用 layer-first

app-tenant-server/src/main/java/io/leandev/app/
├── entity/{domain}/ # 領域實體、狀態與值物件
├── repository/{domain}/ # JPA 查詢與持久化
├── service/{domain}/ # 業務規則與交易邊界
├── controller/{domain}/ # HTTP 契約與權限檢查
├── dto/{domain}/ # 明確的輸入/輸出契約
├── security/{domain}/ # resource:action 權限常數
├── feature/{id}/ # 框架功能的整合與應用端配置
├── config/ # 跨功能的 Spring 配置
├── initializer/ # 啟動初始化
└── handler/ # 應用端例外處理擴充

依賴方向保持單向:

Controller → Service → Repository → Entity

DTO
  • Controller 負責 HTTP 形狀、狀態碼、分頁標頭與授權,不承載核心業務規則。
  • Service 是交易邊界,協調 Repository、檔案、通知等能力。
  • Repository 封裝資料存取,不處理 HTTP 或角色判斷。
  • Entity 保持領域狀態與不變量;對外契約不同時由 DTO 或投影轉換。

feature/{id} 專門放框架功能的應用端整合,例如 cache、file、tenant 配置。商品、客戶、訂單等參考業務仍放在上述業務分層,不應混入 feature/

基礎設施能力的選擇

後端採 appfuse-server first,但不以 class 名稱或 package 判斷是否重造框架,而看程式碼 承擔的責任。實作前先查目前依賴版本的 guides、公開 API 與 CHANGELOG,再依序嘗試公開 primitive、application configuration、adapter 與既有 primitives 的 composition。 查核入口請參閱 Server 使用指南Server API

應用端若自行承擔下列責任,就屬於新的 infrastructure primitive,即使只服務單一功能、沒有 跨產品重用也一樣:

  • authentication/JWT/session、authorization、tenant resolution 或 security context。
  • 全域錯誤 envelope、HTTP transport/retry/serialization。
  • file storage/download/streaming/signed link 與通用 content pipeline。
  • notification outbox、mail delivery、scheduler 與通用 retry orchestration。
  • cache、encryption、audit、clock、通用 JPA mapping/query abstraction。

依 Domain Model 與 API Specification 實作 Entity、Repository、Controller、DTO、mapper、 query、業務 Service/domain policy,不算自製 infrastructure primitive。單一外部系統的 domain adapter 也可以留在專案;若它同時建立通用 transport、security、retry 或 serialization 層, 則該部分仍需按 primitive 處理。

框架與 composition 都不足時,AI 必須先讓人類在「調整設計並使用框架」、「擴充 appfuse-server 後再消費」、「批准專案內例外」三者中明確決策。專案內例外須在 primitive 定義處留下 @framework-primitive-exception,記錄批准日期、查核的框架版本/公開 API 與 例外理由;責任擴張時需重新批准,標記不可複製給另一個 primitive。通用缺口預設走框架 RFC;是否跨產品只決定落點,不決定它是不是基礎設施 primitive。

多租戶隔離

Entity 宣告

需要租戶隔離的 Entity 繼承 AuditableTenantEntity。基類使用 Hibernate 原生 @TenantId,因此一般查詢與 load-by-key 都會依目前 Session 的租戶 過濾:

@Entity
public class Product
extends AuditableTenantEntity
implements Stateful<ProductStatus> {

@Id
@GeneratedValue(strategy = GenerationType.UUID)
@JdbcTypeCode(SqlTypes.VARCHAR)
@Column(length = 36, nullable = false, updatable = false)
private UUID id;

@Enumerated(EnumType.STRING)
private ProductStatus status = ProductStatus.ACTIVE;
}

不要新增 TenantFilterAspect、手動 enableFilter(),也不要在每個 Repository 重複比對 tenantId。這些是舊式 Hibernate Filter 的補強方式, 不適用目前的 @TenantId 實作。

Context 與交易邊界

HTTP 請求由安全流程在建立交易前設定 TenantContext。Hibernate Session 建立時綁定租戶,因此不要在同一個交易/Session 中途切換租戶。

背景工作、seed 或跨租戶批次必須逐租戶執行,而且 runAs 應包住交易的 建立:

TenantContext.runAs(tenantId, () ->
transactionTemplate.executeWithoutResult(status ->
importService.importForCurrentTenant()));

沒有 tenant context 代表 root 視角,查詢可能跨租戶;這是系統級操作能力, 不是一般業務流程的預設值。多租戶 Entity 的新增則必須在明確租戶 context 中執行。

Repository 模式

專案以 EntityManager 搭配 TupleQueryBuilder 建立動態查詢。列表只投影 契約需要的欄位,避免把完整 Entity 當成所有 API 的固定回應:

@Repository
@RequiredArgsConstructor
public class ProductRepository {

private final EntityManager entityManager;

public Page<PropertyMap> findAll(Filter filter, Pageable pageable) {
TupleQueryBuilder<Product> builder =
TupleQueryBuilder.<Product>of(entityManager)
.from(Product.class, "p")
.where(filter)
.select("id", "sku", "name", "status")
.selectAs("price", "basePrice");

QueryRunner<Tuple> runner = new QueryRunner<>(entityManager);
Page<Tuple> rows = runner.findAll(
builder.build(),
builder.buildCountQuery(),
pageable);

TupleConstructor<PropertyMap> constructor =
new TupleConstructor<>(PropertyMap.class);
return rows.map(constructor::construct);
}

public Optional<Product> findById(UUID id) {
return Optional.ofNullable(entityManager.find(Product.class, id));
}
}

@TenantId 的 filter 會套用到 entityManager.find();不需要再做手動租戶 判斷。Repository 仍需避免 native SQL 或自行停用 filter;若確實需要 root 查詢,應把它設計成清楚命名、受限且可稽核的系統操作。

Service 與交易

Service 明確標示讀寫交易。部分更新先移除系統欄位,再把允許的值合併到 既有 Entity:

@Service
@RequiredArgsConstructor
@Transactional
public class ProductService {

private static final List<String> SYSTEM_FIELDS =
List.of("id", "tenantId", "sku");

private final ProductRepository productRepository;
private final tools.jackson.databind.ObjectMapper objectMapper;

@Transactional(readOnly = true)
public Optional<Product> findById(UUID id) {
return productRepository.findById(id);
}

public Product update(UUID id, PropertyMap props) {
Product product = productRepository.findById(id)
.orElseThrow(() ->
new NotFoundException("Product not found: ${0}", id));

SYSTEM_FIELDS.forEach(props::remove);
try {
objectMapper.readerForUpdating(product)
.readValue(objectMapper.writeValueAsString(props));
} catch (Exception error) {
throw new InvalidDataException(
"Failed to update product: ${0}",
error,
error.getMessage());
}
return productRepository.save(product);
}
}

目前 Spring Boot 4 使用 Jackson 3;應用端 import 是 tools.jackson.databind.ObjectMapper,不是 Jackson 2 的 com.fasterxml.jackson.databind.ObjectMapper

PropertyMap 適合動態 filter、投影或部分更新。當端點有穩定且重要的輸入 或輸出契約時,優先建立 DTO,避免 Entity 欄位意外成為公開 API。

Controller 與 HTTP 契約

Controller 只負責邊界工作,並直接使用領域權限常數:

@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {

private final ProductService productService;

@GetMapping
@PreAuthorize("hasAuthority('" + PRODUCT_R + "')")
public ResponseEntity<List<PropertyMap>> findAll(
@RequestParam(required = false) Filter filter,
Pageable pageable) {

Page<PropertyMap> page = productService.findAll(filter, pageable);
HttpHeaders headers = new HttpHeaders();
headers.set("X-Total-Count",
String.valueOf(page.getTotalElements()));
headers.set("X-Page", String.valueOf(page.getNumber()));
headers.set("X-Per-Page", String.valueOf(page.getSize()));

return ResponseEntity.ok()
.headers(headers)
.body(page.getContent());
}
}
  • X-Page 採 0-based,必須與 API Specification 和前端分頁保持一致。
  • 驗證、DTO 映射與狀態碼在 Controller 邊界完成。
  • 業務失敗由 Service 丟出框架語意例外,例如 NotFoundExceptionDuplicateExceptionConflictExceptionInvalidDataException
  • 只有需要應用特有映射時才擴充全域 exception handler;一般錯誤交給框架 的標準處理器。

權限模式

權限值採 resource:action,常數名稱保留 R/W/X/D 的簡寫:

public final class SalesAuthority {

public static final String PRODUCT_R = "product:read";
public static final String PRODUCT_W = "product:write";
public static final String PRODUCT_X = "product:execute";
public static final String PRODUCT_D = "product:delete";
}
後綴action用途
_Rread詳情、列表、搜尋
_Wwrite建立與修改
_Xexecute狀態切換或特殊命令
_Ddelete刪除

功能或領域宣告 authority,專案資料中的 Role.json 再把 authorities 組成 角色。Controller 應檢查 authority,不要硬編碼特定角色名稱。

快取配置

應用端只宣告需要的 cache 與容量策略,生命週期由框架 CacheManager 管理:

@Bean
CacheManager cacheManager(@Value("${cache.path}") String cachePath) {
return CacheManagerBuilder.newCacheManager()
.withPersistence(Path.of(cachePath))
.build();
}

@Bean
Cache<Long, String> userCache(CacheManager cacheManager) {
return CacheBuilder
.newCache(cacheManager, "userCache", Long.class, String.class)
.heap(1000)
.ttl(Duration.ofMinutes(30))
.build();
}

避免自行管理底層 cache 實作,也不要在 Controller 中直接加入 cache-aside 邏輯。

測試邊界

  • API 整合測試使用 @SpringBootTest(webEnvironment = RANDOM_PORT),從 HTTP 邊界驗證授權、租戶隔離、狀態碼與回應契約。
  • 多租戶測試要為每個案例建立並清理正確 context;跨租戶案例應使用不同 使用者/租戶請求,不以手動修改 Entity 欄位模擬。
  • Service 或 Repository 測試若使用 TenantContext.runAs,要確保交易在 runAs 內建立,否則 Hibernate Session 可能已綁定先前的租戶。

下一步