Loading learning content...
Project structure tells us where files live. Package organization tells us how those files relate to each other—what depends on what, what should be public versus private, and how the codebase decomposes into coherent, reusable units.
A package (also called a module, library, or component depending on your language ecosystem) is a unit of organization larger than a class but smaller than an entire application. It groups related classes that collaborate to provide a cohesive capability, while hiding implementation details from the rest of the system.
Mastering package organization is critical for LLD because it determines the stability of your design. Well-packaged code is easy to understand, test, and modify in isolation. Poorly packaged code creates ripple effects where every change touches seemingly unrelated parts of the system.
By the end of this page, you will understand package cohesion and coupling principles, learn to design packages with clear public APIs, master dependency management between packages, and apply these concepts to create architectures that scale gracefully.
Before diving into organization principles, let's establish what we mean by 'package' across different ecosystems. The concept is universal, but terminology varies:
| Language | Term | Example | Typical Contents |
|---|---|---|---|
| Java | Package | com.company.orders | Related classes in a directory hierarchy |
| C#/.NET | Namespace/Assembly | Company.Orders | Types grouped logically; assemblies for deployment |
| TypeScript/JS | Module/Package | @company/orders | ES modules; npm packages for distribution |
| Python | Package/Module | company.orders | Directories with __init__.py; pip packages |
| Go | Package | company/orders | All .go files in a directory |
Characteristics of a well-designed package:
Regardless of language, good packages share common traits:
Single, clear purpose — You can describe what the package does in one sentence without using 'and'.
Cohesive contents — The classes within the package work together; removing any class would break the package's functionality.
Minimal public surface — Only necessary classes, interfaces, and functions are exposed; implementation details are hidden.
Explicit dependencies — What the package needs from outside is clearly stated (through imports, dependencies, or injection).
Independent testability — The package can be tested in isolation, with external dependencies mocked or stubbed.
Versioned evolution — The package can evolve without breaking consumers, following semantic versioning principles.
Ask yourself: 'Could this package be extracted into a standalone library that another project could use?' If the answer is yes, you've likely achieved good package design. If extracting it would require pulling in half the codebase, the package boundaries need work.
Cohesion refers to how strongly related the elements within a package are. High cohesion means the package has a focused purpose; low cohesion means it's a grab-bag of unrelated elements. Robert C. Martin (Uncle Bob) formalized three principles that guide package cohesion:
The tension between principles:
These principles create tension that must be balanced:
The cohesion tension triangle:
REP
╱╲
╱ ╲
╱ ╲
╱ ╲
CCP──────CRP
• Favor REP+CCP: Packages are harder to use (too much baggage)
• Favor REP+CRP: Packages change too often (many small releases)
• Favor CCP+CRP: Packages are hard to reuse (too narrowly focused)
The art of package design is finding the balance appropriate for your context—prioritizing reuse for libraries, change management for rapidly evolving features, or minimal dependencies for stable infrastructure.
In early-stage projects where requirements change frequently, favor CCP. Keep classes that change together in the same package to minimize the blast radius of changes. As the system stabilizes and reuse becomes important, shift toward REP and CRP.
Coupling refers to the degree of interdependence between packages. High coupling means packages are tightly interconnected; a change in one requires changes in many others. Low coupling means packages are independent; they can evolve separately. Three principles guide coupling management:
Visualizing package dependencies:
Package dependencies should form a Directed Acyclic Graph (DAG)—arrows only point one way, and following arrows never brings you back to where you started:
✅ Acyclic (Valid)
┌─────────────┐
│ Core │
└──────┬──────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Orders │ │ Products │ │ Users │
└────┬─────┘ └──────────┘ └────┬─────┘
│ │
└──────────┬────────────────┘
▼
┌──────────────┐
│ Web/API │
└──────────────┘
❌ Cyclic (Invalid)
┌──────────┐ ┌──────────┐
│ Orders │─────────────▶│ Products │
└────▲─────┘ └────┬─────┘
│ │
└─────────────────────────┘
When you discover a dependency cycle, break it using one of these techniques: (1) Extract the shared functionality into a new package that both depend on, (2) Use dependency inversion—add an interface in the stable package that the volatile package implements, (3) Merge the packages if they're truly interdependent and should be one unit.
Stability metrics:
Stability can be measured by counting dependencies:
Instability (I) = Ce / (Ca + Ce)
Where:
• Ca = Afferent couplings (incoming dependencies—packages that depend on this one)
• Ce = Efferent couplings (outgoing dependencies—packages this one depends on)
• I ranges from 0 (maximally stable) to 1 (maximally unstable)
Example:
Domain package has 10 packages depending on it (Ca=10) and depends on nothing except standard library (Ce=0): I = 0/(10+0) = 0 (very stable)WebAPI package has nothing depending on it (Ca=0) and depends on 5 packages (Ce=5): I = 5/(0+5) = 1 (very unstable)SDP requires dependencies to flow toward lower I values. The WebAPI (I=1) depending on Domain (I=0) is correct. Domain depending on WebAPI would violate SDP.
A package's public API is the contract it exposes to consumers. Everything else is implementation detail that can change freely. Thoughtful API design is essential for maintainable package organization.
The barrel pattern:
Many languages support 'barrel' or 'index' files that export the public API of a package:
12345678910111213
// orders/index.ts - The public API of the orders package // Public exports - these form the package's contractexport { Order } from './Order';export { OrderService } from './OrderService';export { OrderStatus, OrderPriority } from './OrderTypes';export type { CreateOrderRequest, OrderDTO } from './OrderTypes'; // NOT exported - internal implementation details:// - OrderRepository (internal data access)// - OrderValidator (internal validation logic)// - OrderMapper (internal DTO mapping)// - OrderEventHandler (internal event handling)OrderRepository interface; keep PostgresOrderRepository internal.v1/, v2/) to support gradual migration.Complex packages can benefit from a facade—a single entry point that coordinates internal components. Instead of exporting OrderService, OrderValidator, OrderMapper separately, export an OrderFacade that provides a unified, simplified API. Consumers get a simpler interface; you retain internal flexibility.
Managing dependencies between packages is one of the most challenging aspects of large-scale software organization. Poor dependency management leads to the dreaded 'ball of mud' architecture where everything depends on everything.
Dependency direction strategies:
| Pattern | Description | When to Use |
|---|---|---|
| Hub and Spoke | All packages depend on a central core; core depends on nothing | Shared domain model with feature-specific packages |
| Layered | Dependencies flow in one direction: UI → Application → Domain → Infrastructure | Traditional enterprise applications |
| Hexagonal | Core domain has no dependencies; adapters depend on core and external systems | Applications requiring high testability and flexibility |
| Event-Driven | Packages communicate via events; minimal direct dependencies | Loosely coupled, distributed systems |
Dependency injection at package boundaries:
When Package A needs functionality from Package B, there are several approaches:
Direct dependency (tight coupling):
// orders/OrderService.ts
import { EmailService } from '../notifications/EmailService';
class OrderService {
private emailService = new EmailService();
// Orders package now depends on Notifications package
}
Interface-based dependency (loose coupling):
// orders/ports/NotificationPort.ts (in Orders package)
interface NotificationPort {
sendOrderConfirmation(orderId: string): Promise<void>;
}
// orders/OrderService.ts
class OrderService {
constructor(private notifications: NotificationPort) {}
// Orders defines what it needs; doesn't know about email specifics
}
// notifications/EmailNotificationAdapter.ts (in Notifications package)
import { NotificationPort } from '../orders/ports/NotificationPort';
class EmailNotificationAdapter implements NotificationPort {
async sendOrderConfirmation(orderId: string): Promise<void> {
// Email implementation
}
}
With interface-based dependencies, the Orders package remains stable—it defines its needs but doesn't depend on implementations. The Notifications package depends on the Orders interface, inverting the typical dependency direction.
Be cautious of transitive dependencies—dependencies of your dependencies. If Orders depends on Notifications and Notifications depends on a specific email library, Orders transitively depends on that library. This can cause version conflicts and bloated dependency trees. Prefer packages with minimal transitive dependencies.
Monorepos contain multiple packages in a single repository, enabling shared tooling, atomic changes across packages, and simplified dependency management. They've become popular at scale (Google, Meta, Microsoft all use monorepos).
Typical monorepo structure:
├── package.json # Root workspace configuration
├── packages/ # Application packages
│ ├── web/ # Web frontend
│ │ ├── package.json
│ │ └── src/
│ ├── api/ # Backend API
│ │ ├── package.json
│ │ └── src/
│ └── mobile/ # Mobile app
│ ├── package.json
│ └── src/
├── libs/ # Shared library packages
│ ├── core/ # Domain models, shared types
│ │ ├── package.json
│ │ └── src/
│ ├── ui-components/ # Shared UI components
│ │ ├── package.json
│ │ └── src/
│ └── utils/ # Shared utilities
│ ├── package.json
│ └── src/
├── tools/ # Build and development tools
│ ├── eslint-config/
│ └── typescript-config/
└── docs/ # Shared documentation
@company/core, @company/utils) to clearly identify internal packages and avoid conflicts with public packages.Monorepos add tooling complexity (workspace management, optimized builds, affected package detection) but reduce coordination complexity (no version mismatches, atomic cross-package changes, shared CI/CD). They work best when packages are tightly related and teams collaborate frequently.
Recognizing common anti-patterns helps you avoid them. Here are the most damaging mistakes in package organization:
common, core, shared) that everyone depends on and that contains everything from domain models to utility functions to configuration. It grows forever and changing anything risks breaking everything.all-services, all-repositories) rather than by domain or feature. This violates single responsibility at the package level.❌ God Package Example:
@company/shared/
├── models/
│ ├── User.ts
│ ├── Order.ts
│ ├── Product.ts
│ └── 47 more models...
├── utils/
│ ├── dateUtils.ts
│ ├── stringUtils.ts
│ └── 23 more utilities...
├── constants.ts (500 lines)
├── types.ts (800 lines)
└── index.ts (exports everything)
Every team depends on this. Any change triggers full rebuild.
✅ Decomposed Packages:
@company/domain-core/ (stable domain types)
@company/domain-orders/ (order-specific domain)
@company/domain-users/ (user-specific domain)
@company/utils-date/ (date utilities)
@company/utils-validation/ (validation utilities)
@company/config/ (configuration only)
Each package has focused responsibility. Changes have minimal blast radius. Teams can depend on only what they need.
Watch for these symptoms: (1) Developers frequently ask which package something belongs in, (2) Simple changes require modifying multiple packages, (3) One package's test suite runs for all changes, (4) The dependency graph has arrows pointing in all directions. These signal that package boundaries need restructuring.
Designing package structure shouldn't be ad-hoc. Here's a systematic process for organizing code into packages:
Example: E-commerce package decomposition
Starting from a monolithic structure, let's design packages:
Step 1: Domain boundaries
Step 2: Common dependencies
Step 3: Resulting structure
@shop/domain-primitives ← Very stable, no dependencies
@shop/catalog ← Depends on domain-primitives
@shop/shopping ← Depends on domain-primitives, catalog
@shop/checkout ← Depends on domain-primitives, shopping
@shop/fulfillment ← Depends on domain-primitives, checkout
@shop/identity ← Depends on domain-primitives
@shop/api-gateway ← Depends on all feature packages
Don't try to design the perfect package structure upfront. Start with broader packages and split them as you discover natural seams. Over-decomposition early creates maintenance overhead before you understand the domain well enough.
Modern tooling can enforce package boundaries and dependency constraints, catching architectural violations before they reach production:
| Category | Tools | Key Features |
|---|---|---|
| Monorepo Management | Nx, Turborepo, Lerna, pnpm workspaces | Workspace coordination, affected detection, caching |
| Dependency Analysis | Dependency Cruiser, Madge, Arkit | Dependency graphs, cycle detection, rule enforcement |
| Architecture Enforcement | ArchUnit (Java), NetArchTest (C#), eslint-plugin-boundaries | Automated architecture tests, layer rules |
| Visualization | Nx Graph, Compodoc, Arkit | Interactive dependency graphs, documentation |
123456789101112131415161718192021222324252627
// .eslintrc.js - Enforce package dependency rulesmodule.exports = { plugins: ['boundaries'], settings: { 'boundaries/elements': [ { type: 'domain', pattern: 'src/domain/*' }, { type: 'application', pattern: 'src/application/*' }, { type: 'infrastructure', pattern: 'src/infrastructure/*' }, { type: 'api', pattern: 'src/api/*' }, ] }, rules: { 'boundaries/element-types': ['error', { default: 'disallow', rules: [ // Domain can only depend on itself { from: 'domain', allow: ['domain'] }, // Application can depend on domain { from: 'application', allow: ['domain', 'application'] }, // Infrastructure can depend on domain and application { from: 'infrastructure', allow: ['domain', 'application'] }, // API can depend on everything { from: 'api', allow: ['domain', 'application', 'infrastructure'] }, ] }] }};Architecture tests serve as executable documentation. They describe the intended package structure and automatically verify that the code matches intent. New developers read these tests to understand the architecture; CI catches violations before merge.
We've explored package organization in depth. Let's consolidate the essential insights:
What's next:
With project structure and package organization covered, we now turn to naming conventions—the art of choosing names that communicate intent, maintain consistency, and make code self-documenting at every level from variables to packages.
You now understand package organization principles that create scalable, maintainable architectures. You can design packages with clear boundaries, manage dependencies intentionally, and use tooling to enforce architectural decisions. Next, we'll explore naming conventions that make code self-documenting.