JavaOmnibus
Architecture Principles
OOAD concept page
ArchitectureSOLIDLayersBoundaries

Architecture turns local class design into system-level clarity.

Where OOAD sharpens object boundaries, architecture sharpens system boundaries. The goal is not a fashionable diagram; it is a structure that supports change, testing, deployment, and team ownership.

Architecture is about change over time

A system architecture is successful when teams can add behavior, replace infrastructure, and isolate failure without rewriting everything. Good architecture preserves options while keeping the system understandable.

That is why architecture should reflect the major axes of change: domain rules, integration boundaries, deployment concerns, observability needs, and operational constraints.

  • Separate domain policy from delivery and persistence details.
  • Make dependency direction intentional.
  • Allow teams to test most logic without booting the whole world.
  • Use modules, packages, and interfaces to enforce boundaries, not just diagrams.

Principles that pair well with Java

SOLID is still useful when applied with judgment. Layered architecture remains practical for many business systems. Hexagonal and clean architecture ideas help when infrastructure churn is high or when tests must stay fast and isolated.

  • Single Responsibility: keep reasons to change narrow.
  • Open/Closed: extend behavior through composition and interfaces.
  • Liskov Substitution: honor the contracts your abstractions promise.
  • Interface Segregation: favor focused contracts over kitchen-sink APIs.
  • Dependency Inversion: point high-level policy at abstractions, not details.

A simple layered shape

A common Java arrangement is presentation -> application -> domain -> infrastructure. Presentation handles HTTP, CLI, or UI concerns. Application coordinates use cases. Domain holds business rules. Infrastructure implements repositories, messaging, and external adapters.

The important part is not the labels. It is that domain rules stay readable and do not depend directly on frameworks or transport details.

Layered use-case example

public record RegisterCustomerCommand(String email, String name) {}

public interface CustomerRepository {
    boolean existsByEmail(String email);
    void save(Customer customer);
}

public final class RegisterCustomerUseCase {
    private final CustomerRepository customerRepository;

    public RegisterCustomerUseCase(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    public void handle(RegisterCustomerCommand command) {
        if (customerRepository.existsByEmail(command.email())) {
            throw new IllegalArgumentException("Customer already exists");
        }
        customerRepository.save(new Customer(command.email(), command.name()));
    }
}

@RestController
final class CustomerController {
    private final RegisterCustomerUseCase registerCustomerUseCase;

    CustomerController(RegisterCustomerUseCase registerCustomerUseCase) {
        this.registerCustomerUseCase = registerCustomerUseCase;
    }

    @PostMapping("/customers")
    void register(@RequestBody RegisterCustomerCommand command) {
        registerCustomerUseCase.handle(command);
    }
}

Architecture styles worth knowing

Layered systems are straightforward and common. Hexagonal architecture emphasizes ports and adapters. Event-driven systems optimize for decoupled reactions and async flow. Modular monoliths keep deployment simple while preserving internal boundaries.

There is no universal winner. The best choice depends on team maturity, operational needs, domain complexity, and the cost of coordination.

  • Layered architecture: easy to understand and common in enterprise apps.
  • Hexagonal / ports and adapters: strong testability and infrastructure isolation.
  • Modular monolith: one deployable, many internal boundaries.
  • Event-driven: useful when workflows span time or services.
  • Microservices: useful only when organizational and operational scale justify the cost.

Practical architectural habits

Write decision records for major tradeoffs. Keep module boundaries visible in the codebase. Review dependencies when adding libraries or frameworks. And resist letting infrastructure concerns colonize the domain layer.

  • Name use cases explicitly.
  • Keep DTOs, entities, and transport contracts distinct.
  • Prefer constructor injection over service locators in application code.
  • Introduce asynchronous boundaries deliberately, not by accident.
  • Measure architecture with tests, dependency rules, and deployment simplicity.

Related pages