データモデル
Astar のデータモデルには、他の一般的な Spring Boot プロジェクトと違う前提が2つあります。すべてのエンティティが二重の ID を持つことと、ORM が Exposed(JPA/Hibernate ではない)であることです。
Dual-ID 規約
すべての永続化エンティティは、内部用の連番 ID と外部公開用の UUID の両方を持ちます。
| ID | 型 | 用途 |
|---|---|---|
id | BIGINT | 内部 PK(自動生成、JOIN のパフォーマンス最適化) |
public_id | UUID | 外部公開 ID(API レスポンス、URL の安定性) |
なぜ2つ要るのか——BIGINT の連番 PK は JOIN が速く、外部キーの複合構成 ((tenant_id, parent_id)) にも向いています。一方で連番 ID をそのまま API に晒すと、テナント間でのリソース総数(≒ 事業規模)が推測できてしまったり、他社の似た採番から自社の連番を逆算されたりします。UUID の public_id を外部公開用に分離することで、内部実装の効率と外部インターフェースの安全性を両立させています。
データフロー:
- Read: JOIN で
public_idを直接取得 → そのまま API レスポンスに使う - Write: API が受け取った UUID →
IdResolverが BIGINT に変換 → INSERT/UPDATE で使う
Forward: API (UUID) → Service → IdResolverService → IdResolver → DB (BIGINT)
Read: DB (JOIN) → Mapper → Domain (UUID via JOIN columns)
IdResolver は UUID→BIGINT 変換をリクエストスコープキャッシュ + グローバル Caffeine キャッシュの2段でキャッシュします。テナント分離は3重に守られています——resolve に渡す tenantId パラメータ自体、複合 FK 制約(クロステナント参照を DB レベルで拒否)、そして防御的な RLS ポリシー。
新しいエンティティ型を idResolver.register<T>() する場合は、テナントコンテキストが確立していることを必ず確認する必要があります(User と Tenant だけがテナントに属さないグローバルなエンティティで、この確認が要りません)。確認を怠ると init/setup コードから呼んだときに RLSContextException が飛びます。
EntityId<T> — 型安全なラッパー
生の UUID をそのままドメインモデルのフィールドに置くことは規約違反です。すべての ID は EntityId<T> という値クラスでラップします。
@JvmInline
@Serializable
value class EntityId<T>(@Contextual val value: UUID) {
override fun toString(): String = value.toString()
}
T によって TagId や TenantId のような別々の型になるので、tagId を誤って tenantId の引数に渡すようなミスはコンパイル時に弾かれます。命名規約は {entity}Id(UUID・外部公開)/ internal{Entity}Id(BIGINT・インフラ層限定)です。
JSONB 柔軟スキーマテーブル
Astar の中核機能である「テーブル」は、固定スキーマの RDB テーブルではなく JSONB で定義を持つ動的テーブルです(Notion のデータベースに近いモデル)。
CREATE TABLE public.tables (
id BIGINT GENERATED ALWAYS AS IDENTITY,
public_id UUID NOT NULL DEFAULT gen_random_uuid(),
tenant_id BIGINT NOT NULL,
workspace_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
properties JSONB NOT NULL DEFAULT '{}'::jsonb, -- PropertyDefinition の配列
property_order TEXT[] NOT NULL DEFAULT '{}'::text[],
organize_config JSONB,
...
);
CREATE TABLE public.records (
id BIGINT GENERATED ALWAYS AS IDENTITY,
public_id UUID NOT NULL DEFAULT gen_random_uuid(),
tenant_id BIGINT NOT NULL,
table_id BIGINT NOT NULL,
data JSONB NOT NULL DEFAULT '{}'::jsonb, -- 実データ
position DOUBLE PRECISION NOT NULL DEFAULT 65536,
...
);
tables.properties がスキーマ定義(キー: プロパティキー、値: PropertyDefinition)、records.data が実際のレコードデータです。GIN インデックスで JSONB 内の高速検索を、record_embeddings(pgvector + HNSW インデックス)でセマンティック検索を提供しています。
プロパティ型(テキスト・数値・日付・選択肢など)のバリデーション・スキーマ・UI 設定は PropertyType.kt の configMetadata() を唯一の SSoT としていて、PropertyDefinition.validate() はそこに委譲する薄いアダプタです。新しいプロパティ型を足すときに when (type) 分岐をあちこちに書き散らすのは規約違反で、configMetadata() の1箇所を編集すれば、JSON Schema やフロントの設定型は自動生成されます。
Exposed ORM(JPA/Hibernate ではない)
データアクセス層は Exposed(JetBrains 製)です。Spring Data JDBC からの移行は完了していて、JPA/Hibernate は使っていません。他の Spring Boot プロジェクトの経験や、社外チュートリアルの JPA 前提の知識はほとんど当てはまらないので注意してください。
テーブル定義は Kotlin の object(Exposed table object)が SSoT です。新しいテーブルやカラムを追加するときは、まず Exposed table object を編集し、そこから ./gradlew generateMigration で Flyway マイグレーション SQL を生成します(RLS ポリシーは自動付与)。詳しくは バックエンドガイド/マイグレーション を参照してください。
参照実装
core/tag/ が規約に最も忠実な、最小の完全なモジュールです。Dual-ID・EntityId<T>・Exposed のパターンを実コードで確認したいときは、まずここを読んでください。もう少し複雑な実例(ports/adapters・非同期処理・8つの feature package 分割)を見たい場合は core/organize/ が参考になります。