This document is the project constitution for AI coding agents working in the routee repository.
Design principles here take precedence over implementation convenience.
| Area | Value |
|---|---|
| Project name | routee |
| Base package | org.sopt.routee |
| Java | JDK 25 |
| Framework | Spring Boot 4.0.7 |
| Spring Modulith | 2.0.7 |
| Build | Gradle (Groovy DSL) |
| Dependency management | Spring Boot BOM + Spring Modulith BOM |
| Lombok | Available in all modules |
| OpenAPI | Springdoc OpenAPI 3.0.2 (in routee-app) |
| Git workflow | GitFlow — develop as PR target, main for release |
| Branch naming | {type}/{issue-number}/{description} (e.g. feature/1/common-resources) |
| Commit format | type: 설명 in Korean (e.g. feat: 공통 응답 레코드 구현) |
| PR title | type: subject |
This project is a modular monolith built on Spring Modulith.
- Prefer Domain Events for state propagation between modules over direct calls.
- Module-level event handlers should remain replaceable by external messaging infrastructure (Kafka, RabbitMQ, etc.) without changing the publishing side.
- Do not expose implementation classes across module boundaries.
- Keep each module independently testable — no shared state, no cross-module internal imports.
The internal package is the private implementation area of a module.
- No other module may import any type from another module's
internalpackage — ever. - Modules interact only through another module's
apipackage. - Only the
apipackage is exported through Spring Modulith Named Interfaces. - Direct dependency on another module's implementation class is forbidden.
Everything intended for other modules must reside under the api package.
The types a module may expose to the outside world are:
- UseCase — interface for triggering the module's behavior. A UseCase represents a business capability, not an application service.
- Command — input for a state-changing UseCase method, when another module is the caller
- Query — input for a read-only UseCase method, when another module is the caller
- Result — output returned from a UseCase
- Domain Event — signals that something has happened inside the module
- Type — shared enums/value types referenced by a UseCase signature (e.g.
MemberRole)
A UseCase method that only the owning module's own controller ever calls does not need a
dedicated Command/Query type — it may take primitives, enums, or api.type values directly.
Promote a parameter to api.command / api.query only once another module actually needs
to construct it. DTOs that merely hand a request from a controller to its own module's
service stay under internal.service.dto.
Everything else belongs to internal.
org.sopt.routee.{module}
├── api/ ← published interface — @NamedInterface("api")
│ ├── package-info.java
│ ├── usecase/ ← every module's cross-module entry point
│ │ ├── package-info.java ← @NamedInterface("api")
│ │ └── {Module}UseCase.java ← public
│ ├── result/
│ │ ├── package-info.java ← @NamedInterface("api")
│ │ └── {Entity}Result.java ← public
│ ├── type/ ← shared enums/value types, when needed
│ │ ├── package-info.java ← @NamedInterface("api")
│ │ └── {Entity}Role.java ← public
│ └── command/ · query/ · event/ · port/ ← add only when a use case needs it (see below)
└── internal/ ← private — never import from outside
├── controller/
│ └── dto/ ← request DTOs; mapped to a Command before
│ reaching the service
├── service/
│ └── dto/command/ ← Commands consumed only within this module
├── repository/
├── entity/
├── mapper/
├── listener/
├── config/
├── exception/
└── code/
command, query, and event are added under api only once another module needs to
construct that input or react to that event. routee-external uses api.port instead of
api.usecase — its published contract is a set of integration ports, not a business UseCase.
All sub-packages of api are considered part of the published API.
A module may also declare additional Named Interfaces for infrastructure that must be
exposed only to the Composition Root (e.g. security/ for types that routee-app's
security configuration must reference). These live outside api, are exceptions,
and should be minimized.
Every module exposes exactly one primary published interface, spread across api and its
sub-packages:
// api/package-info.java
@org.springframework.modulith.NamedInterface("api")
package org.sopt.routee.member.api;
// api/usecase/package-info.java
@org.springframework.modulith.NamedInterface("api")
package org.sopt.routee.member.api.usecase;- A package-level
@NamedInterfacecovers only the types declared directly in that package — it does not propagate to sub-packages. Every sub-package ofapithat contains public types must declare its ownpackage-info.java. - Give every one of these
package-info.javafiles the same name ("api"). Spring Modulith merges named interfaces that share a name into a single logical published interface, so every sub-package ofapiin use (usecase,result,type,port,command,query,event) resolves to oneapiinterface from another module's point of view. - Only the
apipackage (and its sub-packages) may be referenced from other modules. - Never annotate an
internalpackage with@NamedInterface. - Verify with
ApplicationModules.of(RouteeApplication.class).verify()(routee-app'sModularityTests) after adding or moving packages — a missingpackage-info.javafails loudly as a boundary violation, not a silent leak.
routee-app is the Composition Root.
- Contains:
@SpringBootApplication,SecurityFilterChain, global configuration. - Must not contain: business logic, domain entities, repositories, services.
- May reference only another module's
apipackage — neverinternal.
Synchronous — when the caller needs an immediate result, call the target module's UseCase.
// Good
authUseCase.login(command)
// Bad — direct impl dependency
authService.
login(command)Asynchronous — when a state change must propagate to other modules, publish a Domain Event. The publishing module does not know who listens.
// Good
applicationEventPublisher.publishEvent(new MemberRegistered(memberId))
// Bad — cross-module impl call
activityService.
initializeFor(memberId)Queries never use events. Read operations always go through a UseCase.
- Events describe something that already happened. Use past tense.
- Good:
MemberRegistered,CourseCompleted,ActivityDeleted - Bad:
InsertMemberEvent,UpdateCourseEvent
- Good:
- Events are immutable. Use
record. - Event listeners orchestrate only: invoke public UseCases or publish additional events. Listeners must not contain business rules or access repositories directly.
ApplicationModuleListenerclasses belong ininternal.listener.- Design listeners so they can be replaced by a message broker (Kafka, etc.) without changing the publishing side.
- JPA entity associations (
@OneToOne,@OneToMany,@ManyToOne) are allowed only within the same module. - Never create JPA entity associations across module boundaries.
- Cross-module references must use identifiers (e.g.
memberId,courseId) instead of entity references. - When additional data from another module is required for command processing, invoke the target module through its public UseCase.
- Read operations may use dedicated read models or read-only DTO projections instead of exposing domain entities.
- In a shared database environment, cross-module SQL joins are allowed only for read models. Such queries must return DTO projections and must not introduce compile-time dependencies on another module's entity classes.
- Never expose another module's entities outside its module boundary.
- Avoid bidirectional associations unless they are truly necessary to maintain aggregate consistency within the same module.
@ManyToManyassociations are forbidden. Always model many-to-many relationships using an explicit junction entity ( association table) so additional attributes and lifecycle can be managed explicitly.
Composition Root and Spring Boot entry point.
- Bean registration and auto-configuration.
- Security (
SecurityFilterChain, filter registration, exception delegation). - Application bootstrap (
@SpringBootApplication). - Module wiring — connects domain modules without owning domain logic.
- Must not contain business logic, entities, repositories, or services.
Shared infrastructure used by all modules.
- Contains cross-cutting concerns: response format, result codes, base exception, global exception handler, validation support, logging abstractions.
- May contain a technical
BaseEntity(@MappedSuperclass) providing auditing fields (createdAt,updatedAt) for domain modules to extend. This is infrastructure, not a domain entity — it has no table, no identifier, and no relationships. - Must not contain: domain entities, repositories, domain services, business logic.
- Has no dependency on any domain module.
Member domain — owns member identity and lifecycle.
- Exposes
MemberUseCasefor the read operations other modules need (currently: resolving token claims by OAuth identity for login). Registration is handled through this module's own controller/service and does not need to be part of the cross-module contract. - Exposes
MemberRoleas a shared type underapi.type. - Owns the
MemberJPA entity andMemberRepository(both internal). routee-authdeclares a Gradle dependency on this module to resolve member identity during login. Other domain modules may do the same, provided the dependency stays directed and non-circular.
routee-auth, routee-member, routee-activity, routee-course
- Each module owns its domain completely.
- Exposes behavior through UseCase interfaces.
- Publishes Domain Events for state changes relevant to other modules.
- Must not import
internaltypes from other domain modules.
Owns all integrations with external systems (third-party APIs, social login providers, etc.).
Uses a port-adapter pattern: public port interfaces define what an integration does; adapter implementations are private in internal.
- Exposes ports under
api.port(e.g.OidcVerifyPort) instead of aUseCase— this module's public contract is "what can be verified/fetched", not a business capability. - Exposes
OAuthProvideras a shared type underapi.type, used by any module that needs to identify a social login provider. - Domain modules depend only on port interfaces — never on adapter implementations.
- Adapters translate third-party exceptions into typed exceptions that extend
BaseException, soGlobalExceptionHandlerhandles them without any caller-side catch blocks. - Must not contain business logic.
- Depended on by
routee-member(OIDC subject verification) androutee-auth(token issuance needs the provider type and, currently, the same verification port).
routee-app → api of every module (composition root)
routee-common ← depended on by every other module
routee-external ← depended on by routee-member, routee-auth
routee-member ← depended on by routee-auth
routee-auth, routee-activity,
routee-course ← no other domain module depends on these (yet)
A domain module depends directly on another domain module's api only when it has a real,
current need for a synchronous result — this is the exception, not the default shape. Prefer
Domain Events when an immediate return value isn't required.
Allowed
- Any module →
routee-common routee-app→ theapipackage of any domain module- A module → its own
internalpackages - Domain module → another domain module's
apipackage only, when a synchronous result is required and the dependency is directed and non-circular
Forbidden
routee-common→ any domain module- Any module → another module's
internalpackage routee-app→ another module'sinternalpackage- Circular Gradle dependencies between modules
When a domain module depends on another domain module via Gradle,
it must use only the target module's api package — never its internal types.
Prefer Domain Events over direct UseCase calls whenever an immediate return value is not required.
Do not introduce circular Gradle dependencies between modules.
Note: Gradle implementation scope does not expose transitive dependencies.
Declare every dependency a module actually uses explicitly.
- Security configuration (
SecurityFilterChain, filter registration) belongs only inroutee-app. - JWT issuance and validation belong only in
routee-auth. - OIDC token decoding (Apple, Kakao JWKS) belongs in
routee-external. Callers use theOidcTokenVerifierport interface. - Other domain modules must not depend on Spring Security directly.
- Spring Security exceptions must be delegated to the MVC exception handling mechanism
(
HandlerExceptionResolver→@RestControllerAdvice) rather than handled inside filters. - Endpoints that do not require authentication are managed through a centralized whitelist.
UseCase
- Names should describe a business capability, not an application operation.
- Good:
LoginUseCase,CreateCourseUseCase,CompleteActivityUseCase - Bad:
AuthService,MemberManager,CourseProcessor
- Good:
Domain Events
- Names should describe a completed business action. Use past tense.
- Good:
MemberRegistered,CourseCreated,ActivityCompleted - Bad:
CreateMember,CreatingMember,MemberCreate,InsertMemberEvent
- Good:
Packages
- The published API of a module resides under the
apipackage. Business concepts are represented by sub-packages ofapi(e.g.api.command,api.query,api.event). - Avoid
manager,helper,processor,util,miscoutside ofinternal. - Inside
internal, technical layer names (controller,service,repository) are acceptable.
A UseCase is always an interface located under api.usecase.
Its implementation belongs to internal.service.
It is the module's cross-module contract — add a method here only for behavior another
module actually needs to call. Other modules depend only on the interface, never the
implementation.
Controllers are implementation details. They always belong to internal.controller
and are never exposed outside the module.
Controllers should remain thin.
- For endpoints local to the module, depend on the module's own
internal.servicedirectly — a UseCase indirection isn't needed until another module has to call the same behavior. - For behavior owned by another module, depend on that module's UseCase interface, never its service implementation.
- Contain no business logic.
- Never access repositories directly.
- Map request objects to Command/Query objects and pass them to the service or UseCase. Do not pass controller request DTOs directly as-is.
- Build responses exclusively using the common response factory (
ApiResponse).
Swagger Documentation Separation
Every controller must implement a {Name}ControllerDocs interface that lives in the same
internal.controller package. All Swagger annotations (@Tag, @Operation, @ApiResponses)
belong exclusively on the interface. The controller class contains only Spring MVC annotations
and implementation logic — no Swagger annotations.
internal/controller/
├── AuthControllerDocs.java ← @Tag, @Operation, @ApiResponses
└── AuthController.java ← @RestController, implements AuthControllerDocs
Services should be cohesive — one service should represent one business capability.
- Services always belong to
internal.service. - Contain all business logic for the module.
- Never depend on types in the controller layer.
- Never communicate with another module's service directly — use a UseCase instead.
- Call repositories for persistence.
- Publish Domain Events when state changes require cross-module propagation.
- Repositories always belong to
internal.repository. - Are never exposed outside the module.
- Are accessed only by services.
- Contain persistence logic only — no business logic.
Conversion between api contracts (Command, Query, Result) and internal entities is done
by a dedicated Mapper, not inline in the service or via static factory methods on the entity.
- Mappers always belong to
internal.mapper. - One Mapper per aggregate (e.g.
MemberMapperconvertsRegisterCommand↔Member). - Are Spring components injected into services via constructor injection, consistent with the rest of the codebase's DI convention.
- Contain only field-to-field translation — no persistence calls, no cross-module lookups, no validation beyond what the target type's constructor already enforces.
- Never imported outside the module; only the owning module's services depend on them.
- Are immutable. Use
recordunless mutability is required. - Contain no business logic.
- Request DTOs live in
internal.controller.dto. Do not reuse them as Commands — always map to a Command object before passing it to the service. - A Command/Query consumed only by its own module's service stays under
internal.service.dto.command. Move it toapi.command/api.queryonly once another module needs to construct it. - A Result returned across a module boundary belongs under
api.result.
Only packages under api may contain public contracts intended for other modules.
- Implementation classes should remain package-private whenever possible.
- Avoid declaring public classes under
internalunless required by Spring or JPA.
Dependency Injection
- Use constructor injection. Field injection (
@Autowired) is forbidden. - Prefer
@RequiredArgsConstructorwithfinalfields.
Java 25 Features
- Prefer
recordfor immutable data carriers (DTOs, Commands, Results, Events). - Use
sealed interfaceto model closed type hierarchies. - Use switch expressions instead of switch statements.
- Consider virtual threads for I/O-bound operations where appropriate.
Optional
- Use
Optionalonly as a method return type. - Never use
Optionalas a field type or method parameter.
Exceptions
- All application exceptions extend the common base exception class.
- Never
throw new RuntimeException(...)directly. - Business validation failures should throw domain exceptions — do not return
nullorbooleanflags to signal failure. - Translate third-party exceptions (JWT, HTTP client, etc.) into domain exceptions immediately at the boundary.
- Never expose third-party exception types beyond the module boundary.
Logging
- Use SLF4J
Logger.System.out.printlnis forbidden.
Transactions
- Keep transaction scope as small as possible.
- Use
@Transactional(readOnly = true)for read-only operations.
JPA
- Review every association for N+1 risk before merging.
- Do not use
EAGERfetch without a documented reason.
Configuration Classes
- Configuration classes that are purely internal to a module belong in that module's
internal.configpackage. - Cross-module configuration (Security, global beans) belongs in
routee-app. - Avoid exposing implementation beans as public APIs. Inject UseCase interfaces wherever possible.
BOM and Versions
- Omit versions for dependencies managed by the Spring Boot BOM or Spring Modulith BOM.
- Always specify a version for dependencies not in the BOM.
- Prefer testing UseCases over controllers whenever possible.
- Test public APIs, not implementation details.
Do not test private methods or
internaltypes directly from outside the module. - Mock external systems (third-party APIs, message brokers) at module boundaries.
- Keep module tests independent — a test for
routee-authmust not requireroutee-activityto be running. - Integration tests that cross module boundaries should go in
routee-app.
This project is designed to evolve without breaking module boundaries:
- UseCase interfaces are transportable — a UseCase can be exposed as REST, gRPC, or any other protocol by changing only the adapter layer.
- Domain Events are migratable — the event bus can be replaced with Kafka or RabbitMQ without changing the domain logic or the publishing side.
- Module boundaries are maintained with MSA extraction in mind — each module could become an independent service if needed.
Adapters may change. Domain must not.
Keep module coupling loose. Never introduce shortcuts that violate module boundaries.
The common infrastructure is complete. routee-auth, routee-member, and routee-external are
actively implemented, each with a full api/internal split.
routee-activity has its JPA entities and repositories in place but no service, api.usecase,
or controller layer yet — treat it as schema-first, not yet behavior-complete.
routee-course is a pure placeholder (package-info.java only).
Do not
- Add domain logic to modules that have not started implementation.
- Add dependencies not in the BOM without explicit confirmation.
- Commit secrets or environment-specific values in
application-*.yml. - Duplicate abstractions that already exist in
routee-common.
Conventions
- Human-facing commit messages and comments: Korean.
- AI-facing architecture guidance: English.
When modifying existing code:
- Prefer extending existing abstractions over introducing new ones.
- Follow existing package and naming conventions in the module you are working in.
- Preserve module boundaries — do not add imports that cross
internalboundaries. - Avoid unnecessary refactoring outside the scope of the requested task.
- Keep changes focused — modify less rather than more when in doubt.
When generating new code:
- Place types in the correct layer and package according to these principles.
- Do not create new modules or cross-cutting abstractions without explicit instruction.
- Use existing common infrastructure (
ApiResponse,BaseException, etc.) rather than creating alternatives.
When implementation convenience conflicts with architecture principles, always choose architecture.
Never violate module boundaries for the sake of shorter or simpler code. A clean boundary that requires slightly more code is always preferable to a shortcut that couples modules in ways that are hard to undo.
When uncertain, prefer consistency with existing modules over introducing a new pattern.
When in doubt, prefer:
- clarity over cleverness
- loose coupling over shared state
- explicit boundaries over implicit convenience
The project prioritizes, in order:
- Explicit module boundaries
- Loose coupling
- Replaceable infrastructure
- Business-oriented APIs
- Long-term maintainability
Short-term implementation convenience should never compromise these goals.