Skip to main content

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

PathRole
lumie-backend/modules/tenant/src/main/java/com/lumie/tenant/adapter/in/web/TenantController.javaPublic and authenticated tenant endpoints
lumie-backend/modules/tenant/src/main/java/com/lumie/tenant/application/service/{TenantCommandService,TenantQueryService,TenantRegistrationService,TenantLogoService}.javaLifecycle, query, owner-registration, and logo flows
lumie-backend/modules/tenant/src/main/java/com/lumie/tenant/adapter/in/internal/TenantServiceAdapter.javaPublished in-process tenant API consumed by other modules and schedulers
lumie-backend/modules/tenant/src/main/java/com/lumie/tenant/domain/entity/Tenant.javaGlobal tenant aggregate
lumie-backend/modules/tenant/src/main/resources/db/migration/public/V1__create_platform_tables.sqlOriginal 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}.sqlCurrent source of truth for branding, public routing IDs, custom domains, and onboarding completion

Public Surface

EndpointPurpose
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/tenantsCreate a tenant record and activate it
GET /v1/tenants/{slug}, GET /v1/tenantsTenant reads
PATCH /v1/tenants/{slug}Update tenant info, contact fields, custom ID, and hidden sidebar items
POST /v1/tenants/{slug}/complete-onboardingPersist 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}/reactivateLifecycle state transitions
POST /v1/tenants/{slug}/logo, DELETE /v1/tenants/{slug}/logo, GET /v1/tenants/{slug}/logoTenant branding asset management

Internal Surface And Dependencies

SurfaceRole
lumie-backend/libs/internal-api/src/main/java/com/lumie/tenant/api/TenantService.javaPublished 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.javaAFTER_COMMIT event consumed by billing for trial provisioning
TenantLogoStoragePortObject-storage abstraction used by TenantLogoService
Cache keys in CacheConfigTenant-by-slug and tenant-by-domain lookups are cached and explicitly evicted on updates

Aggregate And Registry Fields

Field areaNotes
slugStable internal identifier used across headers, JWT claims, and internal APIs
customIdWhite-label URL segment used by homepage and public routing
customDomainOptional host-based public routing key
statusPENDING, PROVISIONING, ACTIVE, SUSPENDED, or DELETED
logoObjectKeyBranding asset location in object storage
onboardingCompletedAtMarks completion of the owner onboarding flow
hiddenSidebarItemIdsJSON 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.
  • TenantRegistrationService and TenantCommandService.createTenant(...) both publish TenantCreatedEvent; downstream provisioning happens after commit.
  • completeOnboarding(...) requires OWNER auth and rejects requests whose path slug does not match the authenticated tenant context.
  • TenantLogoService enforces a 5 MiB size limit and a small allowlist of image MIME types.
  • deleteTenant(...) marks the tenant DELETED; 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 lowercases host, and TenantLogoService still enforces the documented MIME-type and 5 MiB upload limits.