Modules & Packages — Practice Tasks¶
12 hands-on exercises on package and module structure. Every task gives you a real directory tree or import graph, a precise instruction, and a collapsible full solution with the reorganized layout and the reasoning behind it. Languages rotate across Go, Java, and Python so the idea travels, not the syntax. Easy → hard.
Table of Contents¶
- Task 1 — Package-by-layer → package-by-feature (Go) · easy
- Task 2 — Dismantle a
utilsdumping ground (Python) · easy - Task 3 — Merge over-fragmented one-class packages (Java) · easy
- Task 4 — Hide internals behind a narrow public API (Python) · medium
- Task 5 — Shrink an over-broad public API with
internal/(Go) · medium - Task 6 — Java package-private to seal a feature (Java) · medium
- Task 7 — Break a circular dependency by extracting an interface (Go) · medium
- Task 8 — Break a cycle by moving a shared type (Python) · medium
- Task 9 — Fix a cross-layer reach (Java) · medium
- Task 10 — Stop re-exporting a third-party type (Go) · hard
- Task 11 — Enforce a dependency rule with a tool (import-linter / ArchUnit / depguard) · hard
- Task 12 — Package architecture audit (open-ended) · hard
How to Use¶
- Read the tree first. Most of these tasks are won or lost at the directory level — the code is secondary. Sketch the target layout on paper before opening the solution.
- Trace the arrows. For every dependency task, draw the import graph. A package design is correct when the arrows form a DAG that points inward (toward stable, abstract code) and never loops.
- Run the compiler / linter. Go (
go build ./...plusdepguard), Java (ArchUnittests), and Python (import-linter) will all prove a layering claim. A green build is the only acceptable evidence that a refactor preserved behavior and the rule holds. - One smell at a time. Each task isolates a single structural smell. Task 12 asks you to find all of them at once — save it for last.
The dependency direction every task is driving toward:
Arrows point toward the stable, abstract center. Outer rings depend on inner rings, never the reverse, and inbound adapters never skip the application layer to touch an outbound adapter directly.
Task 1 — Package-by-layer → package-by-feature (Go)¶
Difficulty: easy
Scenario. A small order-management service is organized by technical layer. Every feature is smeared across four packages, so a one-line change to "orders" touches four directories and four code reviewers.
internal/
├── handlers/
│ ├── order_handler.go // package handlers
│ ├── payment_handler.go
│ └── user_handler.go
├── services/
│ ├── order_service.go // package services
│ ├── payment_service.go
│ └── user_service.go
├── repositories/
│ ├── order_repo.go // package repositories
│ ├── payment_repo.go
│ └── user_repo.go
└── models/
├── order.go // package models
├── payment.go
└── user.go
Instruction. Reorganize into package-by-feature. Keep layering inside each feature, but make the feature the top-level unit. State which symbols can now stop being exported.
Solution
internal/
├── order/
│ ├── handler.go // package order
│ ├── service.go // package order
│ ├── repository.go // package order
│ └── order.go // package order — the Order type
├── payment/
│ ├── handler.go // package payment
│ ├── service.go
│ ├── repository.go
│ └── payment.go
└── user/
├── handler.go // package user
├── service.go
├── repository.go
└── user.go
Task 2 — Dismantle a utils dumping ground (Python)¶
Difficulty: easy
Scenario. Every project grows one. utils.py is now 600 lines of unrelated functions that half the codebase imports, making it a magnet for circular imports and a chokepoint in code review.
# app/utils.py
def slugify(text: str) -> str: ...
def parse_iso_date(s: str) -> date: ...
def retry(fn, attempts=3): ...
def deep_merge(a: dict, b: dict) -> dict: ...
def format_money(cents: int, currency: str) -> str: ...
def validate_email(addr: str) -> bool: ...
def chunk(seq, size): ...
def send_slack_alert(msg: str) -> None: ... # imports the slack SDK
def load_feature_flags() -> dict: ... # reads from the database
Instruction. Replace the single utils module with cohesive modules named after what they do. Identify which "utils" are not utilities at all.
Solution
app/
├── text.py # slugify, chunk
├── dates.py # parse_iso_date
├── money.py # format_money (better: a Money value object)
├── validation.py # validate_email
├── resilience.py # retry
├── collections.py # deep_merge, chunk
├── notifications/
│ └── slack.py # send_slack_alert — NOT a utility; it has a dependency + I/O
└── feature_flags.py # load_feature_flags — NOT a utility; it owns DB state
Task 3 — Merge over-fragmented one-class packages (Java)¶
Difficulty: easy
Scenario. A well-meaning team applied "one class per package" literally. The pricing feature is shredded into five packages, none of which has any reason to exist independently.
com/acme/pricing/
├── discount/
│ └── Discount.java // package com.acme.pricing.discount
├── discountcalculator/
│ └── DiscountCalculator.java // package com.acme.pricing.discountcalculator
├── pricerule/
│ └── PriceRule.java
├── priceruleengine/
│ └── PriceRuleEngine.java
└── taxrate/
└── TaxRate.java
Every class is public and every cross-reference needs an import.
Instruction. Merge into a cohesive package structure. Decide what should stop being public.
Solution
com/acme/pricing/
├── Discount.java // public — part of the API
├── DiscountCalculator.java// public — the entry point
├── PriceRule.java // package-private (no modifier)
├── PriceRuleEngine.java // package-private — internal mechanism
└── TaxRate.java // public — a shared value object
package com.acme.pricing;
public final class DiscountCalculator {
private final PriceRuleEngine engine; // collaborator is package-private
public DiscountCalculator() {
this.engine = new PriceRuleEngine(); // no import needed; same package
}
public Discount calculate(Cart cart) { ... }
}
// Note: no `public` keyword — visible only within com.acme.pricing
final class PriceRuleEngine {
Discount apply(List<PriceRule> rules, Cart cart) { ... }
}
final class PriceRule { ... } // also package-private
Task 4 — Hide internals behind a narrow public API (Python)¶
Difficulty: medium
Scenario. A payments package exposes everything by accident. Because Python has no private, consumers have started importing internal helpers, and now any rename to an internal function breaks downstream code.
payments/
├── __init__.py # empty — so `from payments import *` grabs everything
├── gateway.py # class StripeGateway, class PaypalGateway
├── api.py # def charge(...), def refund(...) <- the intended API
├── _http.py # low-level HTTP retry/signing helpers
└── validation.py # def _normalize_card(...), def luhn_check(...)
Downstream code is doing from payments.validation import luhn_check and from payments._http import signed_post.
Instruction. Define a deliberate public API. Use __all__ and the _name convention so the intended surface is charge, refund, and a PaymentError, and nothing else.
Solution
payments/
├── __init__.py # the public facade — re-exports only the API
├── _gateway.py # renamed: leading underscore marks it internal
├── _api.py # charge / refund implementation
├── _http.py
├── _validation.py # renamed from validation.py
└── errors.py # PaymentError (public — consumers must catch it)
Task 5 — Shrink an over-broad public API with internal/ (Go)¶
Difficulty: medium
Scenario. A reusable ratelimit module exports its entire implementation. External modules now import its internal tokenBucket and clock types, so the maintainer can't change them without breaking the ecosystem.
github.com/acme/ratelimit/
├── ratelimit.go // package ratelimit — Limiter, NewLimiter (the API)
├── bucket.go // package ratelimit — TokenBucket (leaked detail)
├── clock.go // package ratelimit — Clock, RealClock (leaked detail)
└── store.go // package ratelimit — RedisStore (leaked detail)
Instruction. Use Go's internal/ mechanism to make TokenBucket, Clock, and RedisStore unimportable from outside this module, while keeping Limiter and NewLimiter public.
Solution
github.com/acme/ratelimit/
├── ratelimit.go // package ratelimit — Limiter, NewLimiter (public API)
└── internal/
├── bucket/
│ └── bucket.go // package bucket — TokenBucket
├── clock/
│ └── clock.go // package clock — Clock, RealClock
└── store/
└── store.go // package store — RedisStore
// ratelimit.go
package ratelimit
import (
"github.com/acme/ratelimit/internal/bucket"
"github.com/acme/ratelimit/internal/clock"
)
type Limiter struct {
bucket *bucket.TokenBucket // internal types used freely *inside* the module
clock clock.Clock
}
func NewLimiter(rate int, per time.Duration) *Limiter {
return &Limiter{
bucket: bucket.New(rate, per),
clock: clock.Real{},
}
}
func (l *Limiter) Allow() bool { ... }
Task 6 — Java package-private to seal a feature (Java)¶
Difficulty: medium
Scenario. An inventory feature exposes a public reservation engine and its public mutable state object. Other teams have started constructing Reservation directly and calling engine internals, bypassing the invariants enforced by the entry point.
com/acme/inventory/
├── InventoryService.java // public — intended entry point
├── ReservationEngine.java // public — should be internal
├── Reservation.java // public, mutable, public setters — dangerous
└── StockLevel.java // public value object — legitimately shared
Instruction. Use Java visibility (package-private + sealed construction) so that only InventoryService can drive ReservationEngine, and Reservation cannot be mutated or constructed from outside the package.
Solution
com/acme/inventory/
├── InventoryService.java // public — the only entry point
├── ReservationEngine.java // package-private
├── Reservation.java // public type, package-private constructor, immutable
└── StockLevel.java // public — shared value object
package com.acme.inventory;
public final class InventoryService {
private final ReservationEngine engine = new ReservationEngine();
public Reservation reserve(SkuId sku, int qty) {
return engine.reserve(sku, qty); // only this class can reach the engine
}
}
// package-private: no `public`. Invisible and uninstantiable outside the package.
final class ReservationEngine {
Reservation reserve(SkuId sku, int qty) {
// enforce invariants here
return new Reservation(sku, qty, Instant.now()); // package-private ctor, OK here
}
}
// The TYPE is public so callers can hold and read it,
// but it cannot be constructed or mutated from outside.
public final class Reservation {
private final SkuId sku;
private final int quantity;
private final Instant at;
Reservation(SkuId sku, int quantity, Instant at) { // package-private ctor
this.sku = sku; this.quantity = quantity; this.at = at;
}
public SkuId sku() { return sku; } // read-only accessors
public int quantity() { return quantity; }
public Instant at() { return at; }
}
Task 7 — Break a circular dependency by extracting an interface (Go)¶
Difficulty: medium
Scenario. order and notification import each other. order calls the notifier when an order is placed; notification reads order details to build the message. Go refuses to compile an import cycle, so the build is currently broken.
order ──imports──► notification (order.Place calls notification.SendOrderEmail)
notification ──imports──► order (SendOrderEmail takes an order.Order)
// package order
import "app/notification"
func Place(o Order) error {
// ... persist ...
return notification.SendOrderEmail(o) // needs notification
}
// package notification
import "app/order"
func SendOrderEmail(o order.Order) error { ... } // needs order.Order
Instruction. Break the cycle by having order depend on an interface it owns, with notification implementing it. Apply the Dependency Inversion Principle.
Solution
order ──defines & depends on──► Notifier (interface, declared in package order)
notification ──implements──────► order.Notifier
main ───wires────► injects a notification.EmailNotifier into order.Place
// package order — owns the interface it needs, in terms of its OWN types
package order
type Notifier interface {
NotifyOrderPlaced(o Order) error
}
func Place(o Order, notifier Notifier) error {
// ... persist ...
return notifier.NotifyOrderPlaced(o) // depends on the abstraction, not notification
}
Task 8 — Break a cycle by moving a shared type (Python)¶
Difficulty: medium
Scenario. Python allows the cycle to exist at import time and then explodes with ImportError: cannot import name ... (most likely due to a circular import) depending on import order. user and account each define a class that references the other.
# user.py
from account import Account # circular
class User:
def __init__(self, account: Account): ...
# account.py
from user import User # circular
class Account:
def __init__(self, owner: User): ...
Instruction. Identify why the cycle exists, then break it by moving the genuinely shared abstraction. Do not paper over it with a deferred/inline import.
Solution
The cycle exists because the two classes are *mutually* referential as concrete types. There are two clean fixes; pick based on the real relationship. **Fix A — extract the shared type (when there's a genuine third concept).** The `User`↔`Account` link is really an *ownership* relationship; model it explicitly.identity/
├── owner.py # the shared abstraction both sides reference
├── user.py # imports owner — one direction
└── account.py # imports owner — one direction
# owner.py — no imports from user/account; it is the leaf
from typing import Protocol
class Owner(Protocol):
@property
def display_name(self) -> str: ...
# user.py
from .owner import Owner
class User:
def __init__(self, account_id: str): ... # reference by ID, not object
@property
def display_name(self) -> str: ... # satisfies Owner structurally
# account.py
from .owner import Owner
class Account:
def __init__(self, owner: Owner): ... # depends on the abstraction
Task 9 — Fix a cross-layer reach (Java)¶
Difficulty: medium
Scenario. A controller reaches straight into the repository, skipping the service layer. The "thin" read path looked harmless, but now caching, authorization, and audit logging — all enforced in the service — are silently bypassed for this one endpoint.
com/acme/billing/
├── api/ InvoiceController ── imports ──► persistence.InvoiceRepository ❌ skips service
├── app/ InvoiceService ── imports ──► persistence.InvoiceRepository
└── persistence/ InvoiceRepository
package com.acme.billing.api;
import com.acme.billing.persistence.InvoiceRepository; // ❌ controller → repository
@RestController
public class InvoiceController {
private final InvoiceRepository repo; // reaches past the service
@GetMapping("/invoices/{id}")
public InvoiceDto get(@PathVariable String id) {
return toDto(repo.findById(id)); // no auth, no cache, no audit
}
}
Instruction. Restore the layering so the controller depends only on the application layer. State the enforceable rule.
Solution
package com.acme.billing.app;
@Service
public class InvoiceService {
private final InvoiceRepository repo;
public InvoiceDto getInvoice(String id, Principal caller) {
authorize(caller, id); // the cross-cutting rules the
return cache.get(id, () -> // controller was bypassing now
toDto(repo.findById(id))); // run on every read path
}
}
package com.acme.billing.api;
import com.acme.billing.app.InvoiceService; // ✅ controller → service only
@RestController
public class InvoiceController {
private final InvoiceService service;
@GetMapping("/invoices/{id}")
public InvoiceDto get(@PathVariable String id, Principal caller) {
return service.getInvoice(id, caller);
}
}
Task 10 — Stop re-exporting a third-party type (Go)¶
Difficulty: hard
Scenario. A cache package wraps Redis but leaks the driver type through its API. Every caller now imports go-redis transitively and is coupled to it — swapping the cache backend, or even upgrading the driver across a breaking change, would ripple through the entire codebase.
// package cache
import "github.com/redis/go-redis/v9"
type Client struct { rdb *redis.Client }
// ❌ Returns and accepts the third-party type directly:
func (c *Client) Raw() *redis.Client { return c.rdb }
func (c *Client) SetCmd(ctx context.Context, k string, v any) *redis.StatusCmd {
return c.rdb.Set(ctx, k, v, 0)
}
Callers do cache.New().SetCmd(...).Result() and import "github.com/redis/go-redis/v9" to read the result — the dependency has leaked everywhere.
Instruction. Redesign the API so go-redis is an internal dependency. No redis.* type appears in any exported signature. Define your own error and option types where needed.
Solution
// package cache — go-redis is now entirely hidden behind this boundary
package cache
import (
"context"
"errors"
"time"
"github.com/redis/go-redis/v9" // imported, but never appears in the API
)
var ErrMiss = errors.New("cache: key not found") // OUR error, not redis.Nil
type Client struct{ rdb *redis.Client }
func New(addr string) *Client {
return &Client{rdb: redis.NewClient(&redis.Options{Addr: addr})}
}
// Signatures use only stdlib + our own types:
func (c *Client) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
return c.rdb.Set(ctx, key, value, ttl).Err()
}
func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
v, err := c.rdb.Get(ctx, key).Bytes()
if errors.Is(err, redis.Nil) {
return nil, ErrMiss // translate redis.Nil at the boundary
}
return v, err
}
Task 11 — Enforce a dependency rule with a tool (import-linter / ArchUnit / depguard)¶
Difficulty: hard
Scenario. The team agreed on a layering rule — domain must not depend on infrastructure, and inbound adapters must not skip the application layer — but it keeps regressing because nothing checks it in CI. Convention without enforcement is a suggestion.
Instruction. Pick one language and write the configuration that fails the build when a forbidden import appears. Provide the rule, the config, and what the failure looks like.
Solution
**Python — `import-linter` (`.importlinter` / `setup.cfg`).**[importlinter]
root_package = shop
[importlinter:contract:layers]
name = Layered architecture
type = layers
layers =
shop.handlers
shop.application
shop.domain
# A layer may import lower layers but never higher ones.
[importlinter:contract:domain-purity]
name = Domain must not touch infrastructure
type = forbidden
source_modules =
shop.domain
forbidden_modules =
shop.infrastructure
Broken contracts
----------------
Domain must not touch infrastructure
shop.domain.pricing -> shop.infrastructure.db (l.4)
@AnalyzeClasses(packages = "com.acme.shop")
class ArchitectureTest {
@ArchTest
static final ArchRule layered = layeredArchitecture().consideringAllDependencies()
.layer("Handlers").definedBy("..handlers..")
.layer("Application").definedBy("..application..")
.layer("Domain").definedBy("..domain..")
.layer("Infrastructure").definedBy("..infrastructure..")
.whereLayer("Handlers").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers("Handlers")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure");
@ArchTest
static final ArchRule domainPurity = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..infrastructure..");
}
Architecture Violation: Rule 'Handlers may not be accessed by any layer' was violated (1 times):
Method <InvoiceController.get(String)> calls method <InvoiceRepository.findById(String)>
# .golangci.yml
linters:
enable: [depguard]
linters-settings:
depguard:
rules:
domain:
files: ["**/internal/domain/**"]
deny:
- pkg: "app/internal/infrastructure"
desc: "domain must not depend on infrastructure"
handlers:
files: ["**/internal/handlers/**"]
deny:
- pkg: "app/internal/infrastructure"
desc: "handlers must go through the application layer"
Task 12 — Package architecture audit (open-ended)¶
Difficulty: hard
Scenario. Below is a real-looking module layout. Name every structural smell and give a one-line fix for each — then state the order you'd attack them in.
src/
├── controllers/ # package-by-layer
│ ├── user_controller.py # → imports repositories.user_repo directly
│ └── order_controller.py
├── services/
│ ├── user_service.py
│ └── order_service.py # → imports notifications, which imports order_service
├── repositories/
│ ├── user_repo.py
│ └── order_repo.py
├── notifications/
│ └── email.py # re-exports sendgrid.Mail as our "Email" type
├── models/
│ └── user.py # imports services.user_service (model → service!)
├── utils.py # 480 lines: dates, money, retry, db helpers, slack
├── common.py # 220 lines: "shared stuff", imported by everyone
└── helpers/
└── string_helper.py # one function: titlecase()
Solution
| Smell | Where | One-line fix | |---|---|---| | Package-by-layer | `controllers/` `services/` `repositories/` | Reorganize into `user/` and `order/` feature packages (Task 1). | | Cross-layer reach | `user_controller` → `user_repo` | Route through `user_service`; forbid `controller → repository` with import-linter (Tasks 9, 11). | | Circular dependency | `order_service` ↔ `notifications` | Extract a `Notifier` port owned by `order`; inject the email impl (Task 7). | | Re-exporting third-party type | `notifications/email.py` exposes `sendgrid.Mail` | Wrap it; expose your own `Email`/`EmailError`, keep `sendgrid` internal (Task 10). | | Inverted dependency | `models/user.py` → `services.user_service` | The domain model must not import the service. Move logic into the service or invert via a port; the model is a leaf. | | `utils` dumping ground | `utils.py` (480 lines, mixed concerns) | Split into `text`, `dates`, `money`, `resilience`; relocate the slack + db code — they aren't utilities (Task 2). | | God / "common" package | `common.py` imported by everyone | A package every module imports is a future cycle and a build chokepoint; dissolve it into cohesive, feature-local modules. | | Over-fragmentation | `helpers/string_helper.py` (one function) | Fold `titlecase()` into the `text` module; a package per function is noise (Task 3). | | No enforced boundaries | whole tree | Add an `import-linter` layered + forbidden contract so none of the above can regress (Task 11). | **Order of attack (lowest-risk first):** 1. **Add the enforcement tool first, with the rules failing.** `import-linter` immediately *documents* the target architecture and shows the full damage. Mark known violations as a baseline so the build stays green while you burn them down. 2. **Dissolve `utils` and `common`** into cohesive modules and relocate the non-utilities. This breaks the hub that feeds most cycles and is mechanical/low-risk. 3. **Break the `order_service` ↔ `notifications` cycle** by extracting the `Notifier` port — required before any feature reshuffle. 4. **Fix the inverted `model → service` dependency** so the domain becomes a clean leaf. 5. **Wrap the SendGrid leak** so `notifications` owns its seam. 6. **Reorganize package-by-layer → package-by-feature** last. It's the largest move (touches every file), and doing it *after* the cycles and hubs are gone means you're shuffling a clean graph, not a tangled one. 7. **Flip the linter rules from baseline to hard-fail.** The architecture is now enforced for good. **Reasoning.** Structural refactors are riskiest when the graph still has cycles and hubs — you can't cleanly move a package that everything imports. So the sequence is: *measure* (tooling), *de-hub* (`utils`/`common`), *de-cycle* (ports), *re-point* (inverted deps), then *relayout* (feature packages) on a graph that's already a DAG.Self-Assessment¶
You've understood this chapter when you can, without notes:
- Explain why package-by-feature localizes change better than package-by-layer, and name the one kind of type that legitimately lives in a shared package.
- Spot a
utils/common/helperspackage and articulate two concrete harms it causes (low cohesion + import-graph hub). - Reach for the right privacy lever per language: Go
internal/(compiler-enforced), Java package-private + package-private constructors, Python_name+__all__(convention). - Break a circular dependency three ways — extract an interface/port, move a shared type to a leaf, reference by ID — and say which fits a given situation.
- Recognize a cross-layer reach and explain the policy (auth/cache/audit) it silently bypasses.
- Justify why re-exporting a third-party type defeats the purpose of a wrapper.
- Write a working
import-linter/ ArchUnit / depguard rule that fails CI on a forbidden import. - Audit an unfamiliar tree, list its structural smells, and sequence the fixes so you never restructure a graph that still has cycles.
Related Topics¶
- Chapter README — the positive rules these tasks invert.
- junior.md — the beginner-level definitions of each anti-pattern.
- find-bug.md — buggy layouts where a structural issue hides.
- optimize.md — taking a working-but-tangled module and improving its structure.
- Refactoring — Move Class, Extract Interface, and the mechanics behind these reorganizations.
- Design Patterns — Dependency Inversion and the Facade/Adapter patterns that underpin Tasks 7, 8, and 10.
In this topic