I still remember inheriting a Spring Boot service where every class lived in one package. A simple bug fix turned into an archaeology project: controller logic mixed with database calls, ad‑hoc DTOs in random folders, and tests that depended on internal package ordering. The app “worked,” but every change felt risky. That experience shaped how I structure Spring Boot projects today. When the structure is intentional, onboarding is fast, code reviews are calmer, and refactors become routine rather than heroic. In the next few minutes, I’ll walk you through a modern, practical structure that I’ve used to keep Spring Boot services maintainable as they grow—starting with a simple baseline and then layering in patterns for real‑world requirements like validation, security, and multi‑module builds.
Start with a clear package spine
I recommend a package spine that mirrors how the app behaves, not how the framework is organized. If you build a typical REST API, your core units are “features” or “domains,” not “controllers.” A common starter structure is package‑by‑layer (controllers, services, repositories), but I prefer package‑by‑feature for anything beyond the smallest service. It keeps related code together and avoids the “controller folder with 40 files” problem.
Here’s the feature‑first shape I use:
com.example.billing
├─ BillingApplication.java
├─ config
├─ common
├─ account
│ ├─ api
│ ├─ domain
│ ├─ service
│ └─ persistence
├─ invoice
│ ├─ api
│ ├─ domain
│ ├─ service
│ └─ persistence
└─ payment
├─ api
├─ domain
├─ service
└─ persistence
Why this works:
- Each feature reads like a mini‑app, which makes reasoning and ownership easy.
- You can refactor or extract a feature into its own module without rewriting import paths.
- Teams can split work by feature instead of fighting merge conflicts in shared “service” packages.
If your app is truly tiny, package‑by‑layer is fine. But once you have multiple REST resources, feature packages are the clearest path. Think of it like a bookshelf: grouping by “genre” (feature) makes it easier to find related books than grouping by “hardcover vs paperback” (layer).
Define a stable application boundary
The application boundary is the line between “how the app is used” and “how the app is built.” I draw that boundary at the API layer, and I keep it explicit.
In each feature, I separate:
apifor controllers and transport DTOsservicefor application use casesdomainfor business rules and entitiespersistencefor JPA repositories and database mappings
That separation helps me keep framework concerns out of the business core. In practice, I allow Spring annotations in service and persistence, but I keep domain models as plain Java objects whenever I can. That reduces surprising side effects and keeps tests fast.
A minimal example for an account feature:
// com.example.billing.account.api.AccountController
@RestController
@RequestMapping("/api/accounts")
public class AccountController {
private final AccountService accountService;
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
@PostMapping
public AccountResponse create(@Valid @RequestBody CreateAccountRequest request) {
return accountService.createAccount(request);
}
}
// com.example.billing.account.service.AccountService
@Service
public class AccountService {
private final AccountRepository accountRepository;
public AccountService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
public AccountResponse createAccount(CreateAccountRequest request) {
Account account = new Account(request.name(), request.email());
Account saved = accountRepository.save(account);
return AccountResponse.from(saved);
}
}
// com.example.billing.account.domain.Account
public class Account {
private Long id;
private final String name;
private final String email;
public Account(String name, String email) {
this.name = name;
this.email = email;
}
// getters/setters omitted
}
You can see the boundary: the controller handles HTTP and validation, the service handles use cases, and the domain stays simple. That clarity pays off as you add complexity like retries, workflows, or async processing.
Keep configuration in one predictable place
Configuration sprawl is one of the fastest ways to make a codebase feel messy. I keep all Spring configuration under a config package at the root. That includes:
@Configurationclasses@Beandefinitions- Jackson customizations
- Security configuration
- Caching setup
I also standardize configuration properties:
com.example.billing.config
├─ AppProperties.java
├─ JacksonConfig.java
├─ SecurityConfig.java
└─ WebConfig.java
// com.example.billing.config.AppProperties
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private String publicBaseUrl;
private Duration invoiceGracePeriod;
// getters/setters
}
Then in application.yml:
app:
public-base-url: "https://billing.example.com"
invoice-grace-period: "P7D"
I avoid scattering small configuration bits inside feature packages because it’s a trap: a quick test tweak today becomes a confusing map of config tomorrow. One location makes it discoverable and keeps overall app behavior easy to audit.
Treat DTOs as part of your public contract
Many Spring Boot apps blur the line between persistence models and API models. That makes it tempting to return JPA entities directly in controllers, which is almost always a mistake. You lose control of your API shape and can accidentally expose internal fields.
I recommend using request and response DTOs as your public contract, and keep them in the api subpackage of each feature. Think of DTOs as the adapters between HTTP and your internal model.
Example:
// com.example.billing.invoice.api.CreateInvoiceRequest
public record CreateInvoiceRequest(
@NotNull Long accountId,
@NotBlank String currency,
@Positive BigDecimal amount) {}
// com.example.billing.invoice.api.InvoiceResponse
public record InvoiceResponse(
Long id,
Long accountId,
String status,
BigDecimal amount) {
public static InvoiceResponse from(Invoice invoice) {
return new InvoiceResponse(invoice.getId(), invoice.getAccountId(),
invoice.getStatus().name(), invoice.getAmount());
}
}
This separation keeps your API stable even if your database schema changes. It also makes versioning easier and gives you a clean place to apply validation.
Put shared helpers in a deliberate “common” package
Every app ends up with cross‑cutting helpers: error types, pagination models, id generators, and standard responses. I keep those in a root‑level common package. I avoid the temptation to create a “util” dumping ground—if a class doesn’t have a clear purpose, it doesn’t belong there.
A clean common package often includes:
ErrorResponseandApiErrorPageResponseProblemDetailsFactoryClockProviderIdGenerator
Example:
// com.example.billing.common.PageResponse
public record PageResponse(
List items,
int page,
int size,
long totalItems) {
public static PageResponse of(Page page) {
return new PageResponse(page.getContent(), page.getNumber(),
page.getSize(), page.getTotalElements());
}
}
I keep helper classes small and focused. If a helper grows, I often promote it into its own feature or module.
Design a predictable exception flow
A project with ad‑hoc error handling is painful to debug. I recommend a standard exception flow in every service:
- Domain‑specific exceptions inside features
- A global exception handler in
common - A single API error format
Structure:
com.example.billing.common
├─ ApiExceptionHandler.java
├─ ApiError.java
└─ ErrorCode.java
com.example.billing.invoice
└─ domain
└─ InvoiceNotFoundException.java
// com.example.billing.common.ApiExceptionHandler
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(InvoiceNotFoundException.class)
public ResponseEntity handleInvoiceNotFound(InvoiceNotFoundException ex) {
ApiError error = new ApiError("INVOICENOTFOUND", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity handleValidation(MethodArgumentNotValidException ex) {
ApiError error = new ApiError("VALIDATION_ERROR", "Invalid request");
return ResponseEntity.badRequest().body(error);
}
}
I don’t let controllers catch and map exceptions manually. Centralizing error handling keeps behavior consistent and eliminates boilerplate.
Keep persistence flexible and explicit
JPA and Spring Data make it easy to drop @Entity classes everywhere, but I avoid using the same class for both domain and persistence if the domain has rules not represented in the database. In simpler apps, you can keep them merged, but be deliberate about it.
At a minimum, I keep repositories in a persistence subpackage per feature and mark them clearly:
// com.example.billing.invoice.persistence.InvoiceRepository
public interface InvoiceRepository extends JpaRepository {
List findByAccountId(Long accountId);
}
If I separate domain and persistence, I add mapping functions in the service layer or a dedicated mapper class. This keeps JPA annotations from bleeding into domain rules.
I also prefer explicit database indexes and constraints defined in migrations (Flyway or Liquibase). That keeps performance predictable and makes the schema evolution visible.
Use modules when the project grows
Once a service passes a certain size—usually around 50–70 files per feature—it’s time to consider multi‑module structure. The benefit isn’t technical performance; it’s separation of concerns and build time.
A standard approach:
root
├─ app-web
├─ app-domain
├─ app-persistence
└─ app-integration
app-web: controllers, REST, securityapp-domain: business rules, use casesapp-persistence: JPA, database configapp-integration: external APIs, messaging
I’ve seen this cut build times by 20–40% in medium projects, and it forces clean boundaries. The trade‑off is additional Gradle or Maven configuration, but it pays off once the team grows.
Testing layout that matches your intent
I mirror the main package layout under src/test/java. If the production code is feature‑based, tests should be too. That makes it easy to find unit tests, integration tests, and contract tests.
Example:
src/test/java/com/example/billing
├─ account
│ ├─ api
│ │ └─ AccountControllerTest.java
│ └─ service
│ └─ AccountServiceTest.java
└─ invoice
└─ persistence
└─ InvoiceRepositoryTest.java
I also separate test types by naming convention:
*Testfor unit tests*ITfor integration tests*ContractTestfor API contracts
This makes it trivial to run only fast tests in CI on pull requests and the full suite on main.
Keep your Spring Boot entrypoint minimal
Your @SpringBootApplication class should do one thing: start the app. I avoid adding helper methods, static initializers, or bean declarations there. Those belong in config or feature packages.
@SpringBootApplication
@EnableConfigurationProperties(AppProperties.class)
public class BillingApplication {
public static void main(String[] args) {
SpringApplication.run(BillingApplication.class, args);
}
}
A minimal entrypoint makes it easier to reuse the app in tests, in CLI tools, or in serverless contexts.
Organize configuration for multiple environments
As soon as you have dev, staging, and prod, configuration becomes a source of bugs. I recommend:
application.ymlfor defaultsapplication-dev.ymlfor local dev overridesapplication-prod.ymlfor production settings
When secrets are involved, I push those to environment variables or a secrets manager, and I keep the YAML clean. I also keep environment‑specific files in version control—only secrets live outside.
A common pitfall is hiding config in ad‑hoc @Value fields across the codebase. I keep all config under typed @ConfigurationProperties so that the shape is obvious and errors are caught early.
Common mistakes I see (and how to avoid them)
- Mixing controller logic and service logic. Keep HTTP in
apiand business logic inservice. If you see a@Transactionalmethod in a controller, it’s a smell. - Returning JPA entities directly. This couples the database to the API and makes breaking changes likely.
- Using a global “utils” package. If a helper can’t be described with a specific noun, it probably needs a better home.
- Ignoring validation. Use
@Validwith DTOs and keep validation rules close to the API. - Letting configuration sprawl. Centralize config classes and properties, and keep them consistent.
When to keep it simple
Not every app needs the full structure. If you’re building a small internal tool with two endpoints, a layered structure is fine. I only switch to feature‑based organization when:
- You have more than two or three domain areas
- Multiple developers contribute regularly
- The app is expected to live for more than a year
Over‑engineering early adds friction. But under‑structuring early creates a debt that becomes far more expensive later.
Performance considerations that structure influences
Structure won’t magically speed up your service, but it does influence performance in a few practical ways:
- Clean separation makes caching easier. If you know which service method is the true read path, you can cache there without surprises.
- Isolation of persistence lets you add read replicas or optimize queries without controller changes.
- Modularization can reduce build times and CI execution, which affects developer productivity more than raw runtime speed.
In real services, I typically see response times in the 10–25 ms range for cached endpoints and 40–80 ms for DB‑backed endpoints. Structure won’t change those numbers directly, but it makes performance work focused and safe.
Where modern tooling fits naturally
Today I assume a few modern practices:
- AI‑assisted code reviews that flag architectural drift. I often ask a code assistant to verify that new code fits the feature package rule.
- Automated architecture checks with tools like ArchUnit or custom Gradle/Maven rules. I use those to prevent controllers from accessing repositories directly.
- Documentation‑as‑code, so diagrams and dependency rules live beside the code.
These practices matter because structure is only as strong as the rules that protect it. A one‑time refactor won’t save you if the team drifts back into shortcut patterns.
A practical baseline you can copy today
When I start a new Spring Boot project, I use a small but complete baseline that leaves room to grow. Here’s an example of a minimal structure that already includes production‑friendly features:
com.example.billing
├─ BillingApplication.java
├─ config
│ ├─ AppProperties.java
│ ├─ JacksonConfig.java
│ └─ SecurityConfig.java
├─ common
│ ├─ ApiError.java
│ ├─ ApiExceptionHandler.java
│ ├─ PageResponse.java
│ └─ TimeProvider.java
├─ account
│ ├─ api
│ │ ├─ AccountController.java
│ │ ├─ CreateAccountRequest.java
│ │ └─ AccountResponse.java
│ ├─ domain
│ │ ├─ Account.java
│ │ └─ AccountStatus.java
│ ├─ service
│ │ └─ AccountService.java
│ └─ persistence
│ ├─ AccountEntity.java
│ └─ AccountRepository.java
└─ invoice
├─ api
├─ domain
├─ service
└─ persistence
This gives me a stable contract (api), a small domain, and clear lines between in‑memory logic and database code. It also encourages a consistent mental model for every new feature.
Feature packages vs layer packages: when each wins
I’m not dogmatic about feature packages. There are cases where package‑by‑layer is a better fit. I decide based on team size and scope:
- Package‑by‑layer works when you have one or two domain areas and a stable API.
- Package‑by‑feature wins when your API grows, the domain splits, or teams own different features.
A quick comparison:
- Package‑by‑layer is easy to scan for new developers but scales poorly as folders grow.
- Package‑by‑feature is more effort upfront but keeps large codebases coherent.
If you’re on the fence, start with layer‑based, but use clear namespaces and avoid mixing features in the same class. You can refactor to feature‑based later, but it’s easier if your classes are already focused.
Organizing domain logic beyond entities
Domain objects are not just JPA entities. When the business gets more complex, I create domain‑level services and value objects in domain. These are small classes that encapsulate rules and invariants.
For example, an invoice might have a “payable amount” that includes taxes and discounts. Instead of scattering that logic across services, I keep it close to the domain:
// com.example.billing.invoice.domain.Money
public record Money(BigDecimal amount, String currency) {
public Money add(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(amount.add(other.amount), currency);
}
}
// com.example.billing.invoice.domain.Invoice
public class Invoice {
private final Long id;
private final Money subtotal;
private final Money tax;
public Invoice(Long id, Money subtotal, Money tax) {
this.id = id;
this.subtotal = subtotal;
this.tax = tax;
}
public Money total() {
return subtotal.add(tax);
}
}
This keeps money math centralized and consistent. It also makes unit testing simple because the domain objects are pure Java with no framework dependencies.
Validation strategy that scales
Validation is one of those details that starts small and grows fast. At first, you only validate required fields. Later, you need cross‑field validation, business rules, and conditional constraints.
My approach:
- Use
javax.validationannotations for simple request validation (@NotNull,@Email,@Positive). - Use custom validator classes for cross‑field rules (e.g., "end date must be after start date").
- Use domain methods to enforce invariants that should never be violated.
Example of a custom validator in a feature package:
// com.example.billing.invoice.api.CreateInvoiceRequest
@ValidInvoiceDates
public record CreateInvoiceRequest(
@NotNull LocalDate startDate,
@NotNull LocalDate endDate,
@Positive BigDecimal amount) {}
// com.example.billing.invoice.api.ValidInvoiceDates
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = InvoiceDatesValidator.class)
public @interface ValidInvoiceDates {
String message() default "End date must be after start date";
Class[] groups() default {};
Class[] payload() default {};
}
// com.example.billing.invoice.api.InvoiceDatesValidator
public class InvoiceDatesValidator implements ConstraintValidator {
@Override
public boolean isValid(CreateInvoiceRequest request, ConstraintValidatorContext context) {
if (request == null) return true;
return request.endDate().isAfter(request.startDate());
}
}
This keeps validation close to the request DTO, while deeper business rules remain inside the domain or service layer.
Transaction boundaries that don’t leak
Where you place @Transactional matters. I keep it in the service layer, almost never in controllers or repositories. That creates a clear boundary: the service method defines the business transaction.
Why it matters:
- If controllers are transactional, HTTP details can leak into database behavior.
- If repositories are transactional, it becomes unclear which operations are safe to bundle.
A good pattern:
@Service
public class InvoiceService {
private final InvoiceRepository repo;
public InvoiceService(InvoiceRepository repo) {
this.repo = repo;
}
@Transactional
public InvoiceResponse createInvoice(CreateInvoiceRequest request) {
InvoiceEntity entity = InvoiceEntity.from(request);
InvoiceEntity saved = repo.save(entity);
return InvoiceResponse.from(saved);
}
}
This keeps transaction boundaries consistent and easy to reason about in tests and reviews.
Mapping strategy: keep it explicit
A common source of mess is mapping between layers. I’ve seen projects with half‑manual mapping and half automatic mapping, which becomes impossible to reason about.
I use one of two strategies:
- Manual mapping in service classes for small models.
- Dedicated mapper classes for larger models.
If the mapping logic is more than a few lines, I move it to a Mapper class inside the feature.
// com.example.billing.invoice.service.InvoiceMapper
public class InvoiceMapper {
public InvoiceEntity toEntity(CreateInvoiceRequest req) {
return new InvoiceEntity(req.accountId(), req.currency(), req.amount());
}
public InvoiceResponse toResponse(InvoiceEntity entity) {
return new InvoiceResponse(entity.getId(), entity.getAccountId(),
entity.getStatus().name(), entity.getAmount());
}
}
This prevents mapping logic from bloating service methods.
Security structure that stays sane
Security is often bolted on late, which makes it feel messy. I keep security config inside the root config package, and I keep security‑related support classes in a dedicated security package at the root when it grows.
Example structure:
com.example.billing
├─ config
│ └─ SecurityConfig.java
└─ security
├─ JwtAuthenticationFilter.java
├─ JwtTokenService.java
└─ UserPrincipal.java
The key rule is that controllers should never access security internals directly. If they need the current user, I expose a small helper (e.g., CurrentUserProvider) and keep it in common or security.
Logging that helps, not hurts
Logging patterns are part of structure too. I treat logs as part of the public interface for troubleshooting. I keep logging helpers in common and enforce a few rules:
- Log at the service layer, not controller or repository.
- Log one line per request for success, one for failure.
- Use structured key‑value logging, not plain string concatenation.
If every feature follows the same pattern, production debugging is faster and less noisy.
Organized integrations with external systems
Most real applications call external services: payment providers, email gateways, analytics APIs. If those are scattered across features, it gets confusing fast. I keep integrations in a root integration package or a module when they are large.
Within a feature, I only allow external calls through a service interface. That keeps external dependencies from spreading across the codebase.
Example:
com.example.billing.integration
├─ email
│ ├─ EmailClient.java
│ └─ EmailConfig.java
└─ payment
├─ PaymentGatewayClient.java
└─ PaymentGatewayConfig.java
The feature service depends on PaymentGatewayClient but doesn’t know the details of the HTTP or SDK implementation.
Event-driven structure without chaos
When you introduce events (Kafka, RabbitMQ, etc.), you need a clear place to put them. I use an event subpackage inside each feature for domain events, and a root messaging package for infrastructure.
Example:
com.example.billing
├─ messaging
│ ├─ KafkaConfig.java
│ └─ InvoiceEventPublisher.java
└─ invoice
└─ event
└─ InvoiceCreatedEvent.java
That separates the business meaning of the event (feature) from the transport mechanism (messaging).
API versioning without chaos
If you expect breaking changes, plan for versioning early. I keep versioned API controllers in separate packages:
com.example.billing.account.api.v1
com.example.billing.account.api.v2
If only small differences exist, I still separate them to avoid if (version) logic inside controllers. This is one of the few times I accept duplication for the sake of clarity.
A deeper example: complete feature flow
Here’s a more realistic feature flow that shows the boundaries working together. It includes DTO validation, service logic, mapping, and repository access.
// com.example.billing.invoice.api.InvoiceController
@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {
private final InvoiceService invoiceService;
public InvoiceController(InvoiceService invoiceService) {
this.invoiceService = invoiceService;
}
@PostMapping
public InvoiceResponse create(@Valid @RequestBody CreateInvoiceRequest request) {
return invoiceService.create(request);
}
}
// com.example.billing.invoice.service.InvoiceService
@Service
public class InvoiceService {
private final InvoiceRepository repo;
private final InvoiceMapper mapper;
public InvoiceService(InvoiceRepository repo, InvoiceMapper mapper) {
this.repo = repo;
this.mapper = mapper;
}
@Transactional
public InvoiceResponse create(CreateInvoiceRequest request) {
InvoiceEntity entity = mapper.toEntity(request);
InvoiceEntity saved = repo.save(entity);
return mapper.toResponse(saved);
}
}
// com.example.billing.invoice.persistence.InvoiceEntity
@Entity
@Table(name = "invoices")
public class InvoiceEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long accountId;
private String currency;
private BigDecimal amount;
// getters/setters
public static InvoiceEntity from(CreateInvoiceRequest req) {
InvoiceEntity e = new InvoiceEntity();
e.accountId = req.accountId();
e.currency = req.currency();
e.amount = req.amount();
return e;
}
}
All the layers are predictable. This makes refactoring safe and makes adding fields straightforward.
Edge cases: what breaks when structure is ignored
I’ve seen these specific problems show up repeatedly when structure is weak:
- Controllers start accessing repositories directly, which makes future caching or transactions difficult.
- DTOs are reused in persistence, so a small database change forces an API change.
- Tests become integration‑heavy because domain logic is buried inside Spring components.
The fix is always the same: re‑establish boundaries and move code to the correct layer. It feels tedious in the moment, but it pays off quickly.
Practical scenario: adding a new feature safely
Imagine you need to add a “refund” feature. With a clean structure, I know exactly where to put everything:
com.example.billing.refund
├─ api
├─ domain
├─ service
└─ persistence
I can implement DTOs, a service method, and a repository without touching other features. If I need to expose events or integrate with payments, I can extend the integration package without polluting the refund package with HTTP details.
The key win is that I can add the feature in isolation, which reduces risk.
Practical scenario: extracting a module later
Suppose the invoice feature grows into its own product. With feature‑based packaging, extracting a module is mostly about moving a folder and adjusting dependencies. In a layer‑based package, you’d have to separate invoice controllers, invoice services, and invoice repositories across multiple directories, which is much more work.
This is why I choose feature‑based for any app with a real chance of future extraction.
Dealing with legacy code: incremental cleanup
Not every project starts with a clean structure. If you inherit a messy codebase, I don’t recommend a massive reorganization upfront. Instead, I do small, safe moves:
- Introduce a
commonpackage for shared types. - Start placing new features in a feature‑based layout.
- Gradually move existing classes when you touch them for changes.
This allows the structure to improve over time without halting delivery.
Table: traditional vs modern structure choices
I often use this simple comparison to explain the trade‑offs to teams:
- Traditional: package‑by‑layer, easy to start, harder to scale
- Modern: package‑by‑feature, slightly more setup, easy to scale and extract
- Traditional: shared DTOs/entities, quick initial development, long‑term API fragility
- Modern: distinct DTOs/entities, slower initial setup, stable public contract
- Traditional: scattered config, quick hacks, difficult auditing
- Modern: centralized config, clear governance, fewer surprises
I don’t treat this as a rigid rule. It’s a decision guide for how I want the project to behave in six months, not just tomorrow.
Production concerns: monitoring and ops placement
Monitoring often gets overlooked until an outage hits. I keep operational components at the root or in a monitoring package:
com.example.billing.monitoring
├─ MetricsConfig.java
├─ HealthChecks.java
└─ TracingConfig.java
This keeps ops‑related code discoverable and separate from business features. If your team uses Prometheus, OpenTelemetry, or similar, keep those integration points here rather than spreading them across features.
CI/CD and structure alignment
Structure impacts CI/CD because it influences test selection and build caching. With feature‑based organization and clean modules:
- You can run tests for only the affected feature in PR checks.
- Build caches are more effective because modules are smaller.
- Code ownership is easier to enforce.
These aren’t just theoretical. I’ve seen medium services cut CI time by 25–35% after moving to modular builds.
Architecture enforcement with automated checks
Even with the best intentions, rules decay. I add automated checks so structure doesn’t erode:
- Use ArchUnit to enforce that controllers only depend on service classes.
- Block repository access from
apipackages. - Ensure
domaindoes not depend on Spring packages.
This turns structure into a real, enforceable contract instead of a guideline no one follows.
Documentation that matches the structure
If a new developer can’t understand the structure from docs, it doesn’t matter how clean the code is. I keep lightweight documentation in the root:
- A short
READMEwith the package spine - A simple diagram showing feature dependencies
- A one‑page “how to add a feature” guide
This sounds small, but it reduces onboarding time dramatically.
How I decide: a quick checklist
When I’m unsure about structure choices, I run through this checklist:
- Will this app live longer than a year?
- Will multiple developers contribute regularly?
- Does the domain naturally split into multiple features?
- Do I expect to add integrations or event flows later?
If the answer is “yes” to most of these, I use feature‑based packaging and strong boundaries. If not, I keep it simple and focus on clarity.
Final thoughts
The best structure is the one you can keep consistent. I’d rather see a slightly imperfect structure that everyone follows than a perfect architecture that no one understands. The patterns I’ve outlined here are the ones I’ve seen hold up over years of change, and they’ve saved me from the chaos of “everything in one package.”
Start small, enforce boundaries, and let the structure grow with the product. Your future self will thank you when the next “simple change” doesn’t turn into an archaeology project.


