Skip to content

Instantly share code, notes, and snippets.

@qzchenwl
Last active December 21, 2025 01:02
Show Gist options
  • Select an option

  • Save qzchenwl/6b7f7bd9d043f273639b16bcff665e77 to your computer and use it in GitHub Desktop.

Select an option

Save qzchenwl/6b7f7bd9d043f273639b16bcff665e77 to your computer and use it in GitHub Desktop.
Pragmatic Hexagonal Java: Clean Code, Testability, Fail Fast

Principles

We optimize for three goals:

  1. Clean Code
  2. Testability
  3. Fail Fast

We use a pragmatic, adapted form of hexagonal architecture: take the strengths (clear seams for change and testing) and avoid dogma (no needless layers, no over-abstraction).

Vocabulary

  • Domain: business logic and deterministic flow. Domain may use framework annotations if that helps productivity, but it must not depend on adapters.
  • Adapter: glue code for IO and external systems. Adapter should be very small and should not contain business rules.
  • Port: an interface in domain that describes a capability domain needs from the outside (e.g., payment gateway, email sender).

1) Clean Code

Rules of thumb:

  • Prefer direct code over unnecessary layers/abstractions.
  • Minimize layering: only introduce layers when they add clear value (not for “architecture aesthetics”).
  • Prefer “script-style” straightforward flow for small features: readable, linear code beats over-engineered indirection.
  • Prefer high-quality, well-adopted libraries over verbose low-level code when it reduces boilerplate and improves readability (e.g., use a solid HTTP client library instead of manual HttpURLConnection plumbing).
  • Prefer avoiding checked exceptions in application code. If a library API exposes checked exceptions and you have no meaningful recovery/fallback, use @SneakyThrows to keep code linear and readable instead of repetitive try/catch + rethrow boilerplate.
  • Keep tiny, private data structures close to where they are used (e.g., private static record / private static class) instead of creating global types prematurely.
  • Organize code by feature (vertical slice) rather than by technical type buckets (service/impl/dto/controller/...).
  • Avoid speculative branches/defaults/fallbacks (“just in case”).
  • Delete dead code/config after changes; do not keep unused remnants.

Good example (avoid checked-exception boilerplate):

package com.example.app.domain.checkout;

import lombok.SneakyThrows;
import java.nio.file.Files;
import java.nio.file.Path;

final class ReceiptTemplateLoader {
    @SneakyThrows
    static String load(Path path) {
        return Files.readString(path);
    }
}

Bad example (manual try/catch + rethrow noise):

try {
    return Files.readString(path);
} catch (IOException e) {
    throw new RuntimeException(e);
}

Why: if there is no meaningful fallback/recovery, repetitive try/catch blocks add noise without improving behavior or readability.

Good example (direct, minimal domain flow):

package com.example.app.domain.checkout;

import java.util.List;

public class CheckoutService {
    public Receipt checkout(String userId, List<Item> items) {
        Pricing pricing = price(items);
        OrderId orderId = createOrder(userId, items, pricing.total());
        return new Receipt(orderId, pricing.total());
    }

    private Pricing price(List<Item> items) {
        // ...pure pricing logic...
        return new Pricing(Money.ofCents(12_345));
    }

    private OrderId createOrder(String userId, List<Item> items, Money total) {
        // ...deterministic order creation...
        return OrderId.newId();
    }

    private static record Pricing(Money total) {}
    public static record Receipt(OrderId orderId, Money total) {}
}

Why: the core flow is linear and readable in domain, and the small helper type (Pricing) stays private to avoid type sprawl.

Good example (feature-first layout with top-level domain):

src/main/java/com/example/app/
  domain/
    checkout/
      CheckoutService.java
      PaymentGatewayPort.java
    catalog/
      CatalogService.java
  adapter/
    checkout/
      web/CheckoutController.java
      payment/StripePaymentGatewayAdapter.java
    catalog/
      web/CatalogController.java

Bad example (type buckets; hard to follow a feature end-to-end):

src/main/java/com/example/app/
  controller/
  service/
  service/impl/
  dto/
  adapter/

Bad example (layering without value):

return presenter.present(useCase.handle(facade.checkout(request)));

Why: if these layers only forward parameters/results, they add noise, increase change surface, and accumulate dead code.

2) Testability (Pragmatic Hexagonal Architecture)

Rules of thumb:

  • We use a pragmatic hexagonal architecture to improve testability; take the strengths, avoid dogma.
  • Strict rule: domain must never depend on adapter (e.g., domain code must not import anything under an ..adapter.. package).
  • Maximize domain, minimize adapter: deterministic logic stays in domain; adapter is IO/glue only.
  • Only external, mutable systems belong in adapters (e.g., third-party APIs, object storage, message brokers). Keep deterministic logic in domain whenever it reduces mocks and improves clarity.
  • Outbound adapters should keep semantics close to the external API and avoid business decisions/defaulting/branching.
  • Tests prefer HTTP E2E; domain is never mocked; only external mutable dependencies may be stubbed/mocked.
  • For local-deployable dependencies (DB/Redis/etc), use real instances (isolated per suite) rather than introducing adapters/mocks “for testability”.
  • Reproducibility: freeze non-deterministic boundaries; keep deterministic pipelines real to minimize mocks.

Good example (domain depends on ports; adapter implements them with minimal glue):

package com.example.app.domain.checkout;

import java.util.List;

public interface PaymentGatewayPort {
    PaymentAuthorization authorize(Money amount, PaymentMethod method);
}

public class CheckoutService {
    private final PaymentGatewayPort paymentGatewayPort;

    public CheckoutService(PaymentGatewayPort paymentGatewayPort) {
        this.paymentGatewayPort = paymentGatewayPort;
    }

    public Receipt checkout(String userId, List<Item> items, PaymentMethod method) {
        Money total = Money.ofCents(12_345);
        paymentGatewayPort.authorize(total, method);
        return new Receipt(OrderId.newId(), total);
    }

    public static record Receipt(OrderId orderId, Money total) {}
}
package com.example.app.adapter.checkout.payment;

import com.example.app.domain.checkout.PaymentGatewayPort;

public class StripePaymentGatewayAdapter implements PaymentGatewayPort {
    @Override
    public PaymentAuthorization authorize(Money amount, PaymentMethod method) {
        // Call Stripe SDK/API and map the response; no business rules here.
        return PaymentAuthorization.approved();
    }
}

Why: the core rule is preserved—domain expresses what it needs via a port; adapter is thin glue around an external mutable system.

Good example (E2E HTTP test + stub only external ports; do not mock domain):

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class CheckoutControllerTest {
    @TestConfiguration
    static class Stubs {
        @Bean PaymentGatewayPort paymentGatewayPort() { /* return fixed response */ }
    }
}

Why: the test exercises real HTTP wiring + real domain logic + real DB (if applicable); only the external mutable boundary is stubbed.

Bad example (domain imports adapter / embeds external client directly):

package com.example.app.domain.checkout;

import com.example.app.adapter.checkout.payment.StripePaymentGatewayAdapter; // ❌ adapter import inside domain

Why: this couples domain to IO details, makes tests harder, and breaks the strict boundary.

3) Fail Fast

Rules of thumb:

  • No defensive programming by default: do not add “just in case” guards; only handle exceptions when fallback behavior is explicitly required.
  • In tests: no pre-checks / no “skip if missing”; let it fail loudly.
  • Fail fast on misconfiguration: validate required inputs at startup (prefer InitializingBean#afterPropertiesSet()).
  • No runtime provisioning (environment is a contract): do not modify PATH, do not auto-install/download toolchains, do not create system-level dependencies.

Good example (startup validation):

@Override
public void afterPropertiesSet() {
    if (paymentProperties.getApiKey() == null || paymentProperties.getApiKey().isBlank()) {
        throw new IllegalStateException("Missing payment.api-key");
    }
}

Why: configuration problems surface immediately at startup with a clear error.

Bad example (swallowing errors):

try {
    // do something important
} catch (Exception ignored) {
    return "";
}

Why: this hides the root cause and tends to fail later in harder-to-debug ways.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment