Skip to content
:tech
← Articles

AI-translated. Read the original in Korean

Packaging Strategies for Clean Architecture

Packaging Strategies for Clean Architecture

TL;DR

Consider Package by Feature even when implementing Clean Architecture.

What is a package?

Most people feel better looking at a tidy desk. Perhaps some dislike seeing a desk at all, but a well-organized one is still preferable to a random collection of pens, coffee mugs, computers, and toothbrushes. On a tidy desk, you can find what you need and know where a new item belongs. The same is true of code.

A package groups related code so it is easy to find and change. Languages differ in how packages are defined, managed, and used, but the underlying idea is similar. “Package” in this post is not exactly the same as a Java package, though much of the discussion applies there too.

Just as there are different ways to organize a desk, there are different packaging strategies. Two commonly compared approaches are package by layer and package by feature. A quick look at folder structures illustrates the difference.

Package by Layer

This groups classes by their role. It will be familiar to anyone who uses—or has used—Rails.

controllers/
  ArticlesController.java
  UsersController.java
models/
  Article.java
  User.java
adapters/
  ArticlesAdapter.java
  UsersAdapter.java

Package by Feature

This groups classes by the feature they serve.

article/
  Article.java
  ArticlesAdapter.java
  ArticlesController.java
user/
  User.java
  UsersAdapter.java
  UsersController.java

Package by Feature is better in most cases

Both approaches involve trade-offs, but feature-oriented packaging has been gaining ground. It tends to align better with Package Principles, with many resulting benefits.

Package Principles

  • Reuse-release Equivalence Principle (REP)
  • Common-Reuse Principle (CRP)
  • Common-Closure Principle (CCP)
  • Acyclic Dependencies Principle (ADP)
  • Stable-Dependencies Principle (SDP)
  • Stable-Abstractions Principle (SAP)

The limitations of layer-based packaging become another reason to prefer features as a program grows more complex. Eventually, the structure begins to look like this, and you start to feel that something is wrong.

controllers/
  ArticlesController.java
  CampaignsController.java
  LoginController.java
  PreferencesController.java
  UsersController.java
  ...
models/
  Article.java
  Campaign.java
  Session.java
  Preferences.java
  User.java
  ...
adapters/
  ArticlesAdapter.java
  UsersAdapter.java
  ...

But I’m more used to packaging by layer

We usually learn concepts by role rather than by feature. When studying the Adapter Pattern, for example, we don’t separately study User Adapters and Article Adapters. We learn about adapters, then apply the concept to users and articles. Organizing code the way we learned makes package by layer feel natural: “Where’s the controller? In the controllers folder.”

That approach feels intuitive when a project is new and makes it easy to understand initially. As the project grows, however, changing a single feature increasingly requires changes across several packages. Unclear dependencies between packages can lead to tight coupling.

Clean Architecture packages

Robert C. Martin’s Clean Architecture separates layers clearly and distinguishes interfaces from implementations, supporting flexible, clean software. It may not feel familiar to everyone because popular frameworks don’t necessarily prescribe or provide it out of the box. Used well, though, it helps produce cleaner, more adaptable, and reliable implementations.

What it does not provide is one definitive “follow these steps” implementation guide. How should classes be divided? Should use cases be grouped together or placed in separate feature files? How should packages be organized? Those decisions remain ours.

Clean Architecture: Package by Layer

Perhaps for that reason, Clean Architecture examples often use layer-based packaging. They create domain, data, and presentation packages; put use cases and repository interfaces in domain; and place repository implementations in data. go-cleanarchitecture is one example.

The examples below use Go, where package boundaries are explicit and widely used, to make the differences clearer.

domain/
  repo/
    article_repo.go
    user_repo.go
  usecase/
    article_usecase.go
    article_usecase_test.go
  	user_usecase.go
    user_usecase_test.go
data/
  article_pgrepo.go
  article_pgrepo_test.go
  user_pgrepo.go
  user_pgrepo_test.go

This seems to apply Clean Architecture faithfully because the packages mirror its concepts. But it retains the problems of package by layer described above.

Clean Architecture: Split by Feature, Package by Feature

Other examples first divide the project by feature, then organize each feature internally by layer, as in go-clean-arch.

article/
  usecase/
    article_usecase.go
    article_usecase_test.go
  repo/
    pgrepo.go
  entity.go
  repo.go
  usecase.go
user/
  usecase/
    user_usecase.go
    user_usecase_test.go
  repo/
    pgrepo.go
  entity.go
  repo.go
  usecase.go

Because the root packages represent features, this looks like package by feature. The inner layer-based packages also appear conceptually easy to understand.

In practice, though, a usecase or repo folder will rarely contain more than one implementation: the feature split has already narrowed its contents. If several implementations do belong there, the problems of layer-based packaging return.

Using these packages introduces another issue. Importing by layer name doesn’t tell you which feature you are using. You might distinguish them with method names, such as usecase.NewArticleUsecase(), but colliding package names still require aliases.

r := repo.New() // What kind of repository implementation?
u := usecase.New(r) // Which usecase?

Clean Architecture: Package by Feature

Clean Architecture’s concepts are clear and can produce clean, flexible code. But package names do not have to express those concepts directly. Precisely because the concepts are clear, they can be represented without a separate package for every layer.

article/
  pgrepo/
    pgrepo.go
  entity.go
  repo.go
  usecase.go
  usecase_test.go
user/
  pgrepo/
    pgrepo.go
  entity.go
  repo.go
  usecase.go
  usecase_test.go

Here, packages are organized by feature and filenames distinguish internal roles. The repository is an interface, while its implementation lives in a separate package. That separation ensures changes in the data layer do not affect the domain layer.

pgrepo is the PostgreSQL repository implementation. Other implementations might be sqliterepo, memrepo, or mockrepo. Nesting them inside the feature rather than placing them in a separate data-layer package makes the intended dependency relationships clearer.

At the point of use, intent is clear without unnecessary repetition:

r := pgrepo.New()
u := article.NewUsecase(r)

A consideration

The name pgrepo describes a PostgreSQL implementation, but does not distinguish the article repository from the user repository. Imports therefore need aliases when both are used. Calling it userpgrepo would solve that, but repeats “user” inside the user package. For a comparable example, see https://github.com/gogs/gogs/tree/master/pkg/auth: its github package implements authentication through GitHub, but does not include “auth” in its name.

Clean Architecture: Split by Layer, Package by Feature

Another option is to divide the top level by layer and organize packages inside each layer by feature.

domain/
  article/
    entity.go
    repo.go
    usecase.go
    usecase_test.go
  user/
    entity.go
    repo.go
    usecase.go
    usecase_test.go
data/
  articlerepo/
    repo.go
    repo_test.go
  userrepo/
    repo.go
    repo_test.go

This approach has particular advantages in Java. The top-level layers can be modules rather than Java packages—the term “package” here is narrower than our earlier usage—while the contents use packages. Modules enforce boundaries that Java packages cannot physically enforce; for example, circular module dependencies are prohibited.

Usage can also express intent more clearly:

r := articlerepo.New()
u := article.NewUsecase(r)

The downside is that code for one feature becomes spread across several locations.

Conclusion

There is a real gap between understanding a concept and expressing it in code. What seems clear in documentation can produce many exceptional cases during implementation. The same idea can be implemented in several ways, each with trade-offs. The most intuitive approach isn’t always the right one—just as Square should not necessarily inherit from Rectangle.

Architectural approaches continue to emerge and improve. Choosing an architecture matters, but implementing it still requires many thoughtful decisions. A choice that makes sense now may prove wrong later, or we may change our minds tomorrow. Even so, I believe this process of deliberation and decision-making helps both individuals and teams grow.

P.S. At Buzzvil, we consider Package Principles carefully and choose a packaging strategy suited to the situation. There still isn’t one unique answer in many cases. But by thinking deeply and establishing principles one at a time, we keep moving toward clearer, cleaner implementations. We’re looking for people to join this enjoyable journey—please get in touch.

References

All articles