Tenant Service
This is the reference page for lumie-backend/modules/tenant, the platform module that owns the tenant registry, lifecycle state, public tenant identifiers, onboarding metadata, and branding assets.
Source Paths
| Path | Role |
|---|---|
lumie-backend/modules/tenant/src/main/java/com/lumie/tenant/adapter/in/web/TenantController.java | Public and authenticated tenant endpoints |
lumie-backend/modules/tenant/src/main/java/com/lumie/tenant/application/service/{TenantCommandService,TenantQueryService,TenantRegistrationService,TenantLogoService}.java | Lifecycle, query, owner-registration, and logo flows |
lumie-backend/modules/tenant/src/main/java/com/lumie/tenant/adapter/in/internal/TenantServiceAdapter.java | Published in-process tenant API consumed by other modules and schedulers |
lumie-backend/modules/tenant/src/main/java/com/lumie/tenant/domain/entity/Tenant.java | Global tenant aggregate |
lumie-backend/modules/tenant/src/main/resources/db/migration/public/V1__create_platform_tables.sql | Original tenant registry creation |
lumie-backend/app/src/main/resources/db/migration/public/{V8__inline_tenant_logo,V9__rename_enterprise_to_max_and_add_custom_id,V10__add_custom_domain_fields,V50__custom_id_not_null,V68__tenant_onboarding_completed_at}.sql | Current source of truth for branding, public routing IDs, custom domains, and onboarding completion |
Public Surface
| Endpoint | Purpose |
|---|---|
GET /v1/tenants/public/by-custom-id/{customId} | Resolve a public tenant from the white-label path segment |
GET /v1/tenants/public/by-domain?host=... | Resolve a public tenant from a custom domain |
POST /v1/tenants | Create a tenant record and activate it |
GET /v1/tenants/{slug}, GET /v1/tenants | Tenant reads |
PATCH /v1/tenants/{slug} | Update tenant info, contact fields, custom ID, and hidden sidebar items |
POST /v1/tenants/{slug}/complete-onboarding | Persist onboarding completion data for the authenticated tenant owner |
DELETE /v1/tenants/{slug} | Mark the tenant as deleted |
POST /v1/tenants/{slug}/suspend, POST /v1/tenants/{slug}/reactivate | Lifecycle state transitions |
POST /v1/tenants/{slug}/logo, DELETE /v1/tenants/{slug}/logo, GET /v1/tenants/{slug}/logo | Tenant branding asset management |
Internal Surface And Dependencies
| Surface | Role |
|---|---|
lumie-backend/libs/internal-api/src/main/java/com/lumie/tenant/api/TenantService.java | Published internal API for tenant lookup, validation, active-tenant listing, tenant creation, and plan updates |
lumie-backend/libs/internal-api/src/main/java/com/lumie/tenant/api/TenantCreatedEvent.java | AFTER_COMMIT event consumed by billing for trial provisioning |
TenantLogoStoragePort | Object-storage abstraction used by TenantLogoService |
Cache keys in CacheConfig | Tenant-by-slug and tenant-by-domain lookups are cached and explicitly evicted on updates |
Aggregate And Registry Fields
| Field area | Notes |
|---|---|
slug | Stable internal identifier used across headers, JWT claims, and internal APIs |
customId | White-label URL segment used by homepage and public routing |
customDomain | Optional host-based public routing key |
status | PENDING, PROVISIONING, ACTIVE, SUSPENDED, or DELETED |
logoObjectKey | Branding asset location in object storage |
onboardingCompletedAt | Marks completion of the owner onboarding flow |
hiddenSidebarItemIds | JSON array of allowed menu IDs that the tenant hides in the frontend |
Runtime Flow
The same lifecycle also exists in TenantCommandService.createTenant(...) for the regular controller path.
Contract Notes
customId is intentionally first-set-only and rejects route-like reserved values.
// lumie-backend/modules/tenant/src/main/java/com/lumie/tenant/domain/entity/Tenant.java
if (alreadySet) {
if (!this.customId.equals(incoming)) {
throw new BusinessException(TenantErrorCode.CUSTOM_ID_IMMUTABLE,
"학원 ID는 한번 설정되면 변경할 수 없습니다.");
}
return;
}
The entity also reserves values such as api, login, pricing, and _next so public tenant landing pages do not collide with frontend routes.
Example Contracts
These examples come directly from TenantController, CreateTenantRequest, CompleteTenantOnboardingRequest, PublicTenantResponse, TenantResponse, and TenantLogoService.
Public Tenant Lookup
GET /v1/tenants/public/by-custom-id/acme
HTTP/1.1 200 OK
{
"slug": "inst-acme",
"name": "Acme Academy",
"customId": "acme",
"logoUrl": "/api/v1/tenants/inst-acme/logo"
}
Complete Owner Onboarding
POST /v1/tenants/inst-acme/complete-onboarding
Content-Type: application/json
{
"phone": "02-555-0100",
"address": "Seoul",
"customId": "acme",
"hiddenSidebarItemIds": ["reviews", "sms"]
}
HTTP/1.1 200 OK
{
"slug": "inst-acme",
"status": "ACTIVE",
"customId": "acme",
"onboardingCompletedAt": "<timestamp>",
"hiddenSidebarItemIds": ["reviews", "sms"]
}
Logo Upload
POST /v1/tenants/inst-acme/logo
Content-Type: multipart/form-data
file=@logo.png
HTTP/1.1 200 OK
{
"slug": "inst-acme",
"logoUrl": "/api/v1/tenants/inst-acme/logo"
}
TenantLogoService only accepts image/png, image/jpeg, image/jpg, image/svg+xml, and image/webp, and it rejects files larger than 5 MiB.
Failure, Retry, And Observability
- Tenant creation is persistence-only now. The module no longer provisions per-tenant schemas or runs Flyway at signup.
TenantRegistrationServiceandTenantCommandService.createTenant(...)both publishTenantCreatedEvent; downstream provisioning happens after commit.completeOnboarding(...)requires OWNER auth and rejects requests whose path slug does not match the authenticated tenant context.TenantLogoServiceenforces a 5 MiB size limit and a small allowlist of image MIME types.deleteTenant(...)marks the tenantDELETED; it does not hard-delete the registry row.getPublicTenantByCustomDomain(...)expects the controller to normalize the incoming host to lowercase before it hits the cached query method.
Verification
cd lumie-backend
./gradlew :modules:tenant:test
./gradlew :modules:tenant:test --tests '*TenantCommandService*'
./gradlew :modules:tenant:test --tests '*TenantRegistrationService*'
Expected success signals:
- Gradle exits with
BUILD SUCCESSFUL, and the tenant module tests still cover create, onboarding, and public lookup paths. TenantController.getPublicTenantByCustomDomain(...)still lowercaseshost, andTenantLogoServicestill enforces the documented MIME-type and 5 MiB upload limits.