No Framework, No Pain: Writing Aether Slices

Published: (February 19, 2026 at 05:08 AM EST)
9 min read
Source: Dev.to

Source: Dev.to

Here’s an entire deployable service

@Slice
public interface OrderService {
    Promise placeOrder(PlaceOrderRequest request);

    static OrderService orderService(InventoryService inventory,
                                     PricingEngine pricing) {
        return request -> inventory.check(request.items())
                                   .flatMap(pricing::calculate)
                                   .map(OrderResult::placed);
    }
}

That’s not a simplified example. That’s the actual thing.

  • The annotation processor sees @Slice, reads the factory‑method signature, and generates:
    • the wiring code,
    • the proxy for remote calls,
    • the deployment metadata.

You write one interface and you get a service that scales, fails over, and routes transparently across a distributed cluster.

No @Autowired.
No application.yml.
No @Configuration class.
No @Bean method.
No component scan.
No service locator.
No dependency‑injection container at all.

The factory method is the dependency injection. Its parameters are the declared dependencies. The compiler verifies them; the annotation processor wires them. Nothing to configure, nothing to forget, nothing to debug at 2 AM.

Notice what the factory returns: a lambda. No implementation class. The interface has one method, so the factory returns a lambda that implements it directly—business logic as a function. For slices with multiple methods, a private record captures the dependencies and implements the interface—still no separate Impl class, no file to maintain, no indirection to trace.

Every slice follows two rules

  1. Factory method declares dependencies – what the slice needs from the outside world appears in one place: the factory method signature.

    static OrderService orderService(InventoryService inventory,
                                     PricingEngine pricing) {
        return request -> inventory.check(request.items())
                                   .flatMap(pricing::calculate)
                                   .map(OrderResult::placed);
    }

    Read the factory, know the dependencies. No configuration file can contradict it. No runtime surprise can introduce a dependency the compiler hasn’t seen.

  2. Promise return types – every method returns Promise. This isn’t a stylistic choice; it’s what makes transparent distribution possible. Whether the call is in‑process or cross‑network, the caller sees the same type.

That’s it—two rules. Everything else follows from them.

How the annotation processor classifies factory parameters

What the processor seesWhat it does
@PrimaryDb DatabaseConnector dbResource – provisions from config
InventoryService inventoryExternal slice – generates a network proxy
OrderValidator validatorLocal interface with factory – calls the factory directly

You don’t configure this. You don’t annotate dependencies with @Inject or @Qualifier (except for infrastructure resources). You just list what you need, and the processor figures out how to provide it.

Infrastructure resources

A parameter annotated with @ResourceQualifier is infrastructure—a database connection, an HTTP client, a message queue. The processor provisions it from configuration:

@ResourceQualifier(type = DatabaseConnector.class, config = "database.primary")
public @interface PrimaryDb {}

@Slice
public interface OrderRepository {
    Promise findOrder(FindOrderRequest request);

    static OrderRepository orderRepository(@PrimaryDb DatabaseConnector db) {
        return request -> db.query(request.orderId())
                            .map(OrderResult::fromRow);
    }
}

Remote dependencies

A parameter that is a @Slice interface from another package is a remote dependency. The processor generates a proxy record that delegates to the runtime’s invocation fabric. Your code calls inventory.check(request) as a normal method call; the proxy handles serialization, routing, retry, and fail‑over.

Local dependencies

A parameter that is a plain interface with a static factory method is local. The processor calls the factory directly—no proxy, no network, no overhead.

All three categories together

static LoanService loanService(@PrimaryDb DatabaseConnector db,
                               CreditBureau creditBureau,
                               RiskCalculator riskCalculator) {
    return request -> riskCalculator.assess(request)
                                    .flatMap(risk -> creditBureau.check(request.applicant()))
                                    .flatMap(credit -> persistDecision(db, request, credit));
}
  • Database – from config.
  • Credit bureau – via network proxy.
  • Risk calculator – instantiated locally.

One line declares it all. The processor handles the rest.

Structured error handling

Frameworks train you to throw exceptions. Spring converts them to HTTP status codes, Jackson serializes error responses, and exception handlers map types to messages. It works until someone throws an unexpected exception, and the generic 500 response tells the caller nothing.

Slices use sealed Cause hierarchies:

public sealed interface OrderCause extends Cause {
    OrderCause EMPTY_ORDER = new EmptyOrder("Order must have items");
    OrderCause INSUFFICIENT_STOCK = new InsufficientStock("Insufficient stock");

    static OrderCause insufficientStock(StockStatus stock) {
        return new InsufficientStock("Insufficient stock: " + stock);
    }

    record EmptyOrder(String message) implements OrderCause {}
    record InsufficientStock(String message) implements OrderCause {}
}

Every failure mode is a type. The compiler knows all of them. Pattern matching handles them exhaustively. No surprise NullPointerException masquerading as a 500 error.

Error handling as a business concern

When something fails you should return a failed promise using a fluent style instead of swallowing the exception:

return inventory.checkStock(stockRequest)
                .flatMap(this::verifyAvailability);
private Promise verifyAvailability(StockStatus stock) {
    return stock.sufficient()
        ? completeOrder(stock)
        : OrderCause.insufficientStock(stock).promise();
}
  • The runtime propagates the Cause across the network.
  • The caller receives a typed failure, not a plain string message.
  • The error‑handling contract becomes part of the API, not an after‑thought in a @ControllerAdvice.

Testing without containers

  • No test containers to spin up.
  • No mock server to configure.
  • No @SpringBootTest that loads half the universe.
  • No mocking frameworks – dependencies are interfaces, so you pass lambdas that return exactly what you need.
class OrderServiceTest {

    @Test
    void placeOrder_succeeds_whenInventoryAvailable() {
        InventoryService inventory = request -> Promise.success(new StockResult("RES-123", true));
        PricingEngine pricing = request -> Promise.success(new PriceResult("ORD-456", 99.99));

        var service = OrderService.orderService(inventory, pricing);

        service.placeOrder(request)
               .await()
               .onFailure(Assertions::fail)
               .onSuccess(result -> assertEquals("ORD-456", result.orderId()));
    }

    @Test
    void placeOrder_fails_whenInventoryUnavailable() {
        InventoryService inventory = request -> OrderCause.INSUFFICIENT_STOCK.promise();
        PricingEngine pricing = request -> Promise.success(new PriceResult("ORD-456", 99.99));

        var service = OrderService.orderService(inventory, pricing);

        service.placeOrder(request)
               .await()
               .onSuccess(Assertions::fail);
    }
}
  • Dependencies are interfaces – pass lambdas that return success or failure.
  • The factory method wires them in.
  • No reflection, no class‑path scanning, no context initialization.
  • No Mockito, no when(...).thenReturn(...), no verify(...).

Test startup is instant because there’s nothing to start: no container, no framework, no bean resolution – just objects calling objects.

Slice‑based architecture

A single Maven module can contain as many slices as make sense:

commerce/
  src/main/java/org/example/
    order/
      OrderService.java       # @Slice
    payment/
      PaymentService.java    # @Slice
    shipping/
      ShippingService.java   # @Slice
  • Each @Slice generates its own factory, API artifact, and deployment metadata.
  • The Maven plugin packages them separately.
  • They deploy and scale independently while sharing domain types, build configuration, and repository.

Granular scaling

A slice can be as small as a single method. Because there’s no operational overhead (no container, no load balancer, no per‑service monitoring), you can scale slices individually:

  • One slice serving 50 instances during peak load.
  • Another slice idling at minimum capacity.

With Aether, this is the default behaviour.

What the annotation processor generates

Consider a simple slice with one external dependency:

@Slice
public interface OrderService {
    Promise placeOrder(PlaceOrderRequest request);

    static OrderService orderService(InventoryService inventory) {
        return request -> inventory.check(request.items())
                                   .map(OrderResult::fromAvailability);
    }
}

The processor detects that InventoryService is an external @Slice and generates:

  1. A proxy record that implements InventoryService and delegates every method call to the runtime’s SliceInvokerFacade.
    Your code calls inventory.check(request). The proxy serializes the request, routes it to a node hosting InventoryService, deserializes the response, and returns a Promise.

  2. A factory class that accepts an Aspect and the SliceInvokerFacade, creates the proxy, and wires everything together.

  3. Deployment metadata in META-INF/slice/ – the slice name, its methods, and its dependencies.
    The runtime reads this metadata to build the dependency graph and determine deployment order.

All of this is generated at compile time, verified, and invisible to your business logic.

Incrementally migrating existing Spring code

You don’t need to start from scratch. Any existing Java code can become a slice with a single line.

Legacy Spring service

@Service
public class OrderService {
    @Autowired private InventoryRepository inventory;
    @Autowired private PricingService pricing;

    @Transactional
    public OrderResult processOrder(OrderRequest request) {
        var availability = inventory.checkAvailability(request.getItems());
        if (!availability.isAvailable()) {
            return OrderResult.outOfStock(availability.getMissingItems());
        }
        var quote = pricing.calculateQuote(request.getItems(), request.getCustomerId());
        // ... payment, order creation, notification ...
        return OrderResult.success(order);
    }
}

Wrapped as a slice

@Slice
public interface OrderProcessor {
    Promise processOrder(OrderRequest request);

    static OrderProcessor orderProcessor() {
        var legacyService = createLegacyService();
        return request -> Promise.lift(() -> legacyService.processOrder(request));
    }
}
  • Promise.lift() wraps the synchronous call, catches any exception, and returns a proper Promise with a typed failure instead of a raw stack trace.

The wrapped slice works, but it’s a black box. The peeling pattern opens it up incrementally—one layer at a time—while keeping working code at every step.

Peeling the outer structure

Replace the opaque lift() with a Sequencer where each step is still wrapped:

return Promise.lift(() -> legacyCheckInventory(request))
              .flatMap(inv -> Promise.lift(() -> legacyCalculatePricing(inv)))
              .flatMap(quote -> Promise.lift(() -> legacyProcessPayment(quote, request)));

Now each stage is explicit, testable, and composable, while the overall flow remains a single, type‑safe promise chain.

Migration walkthrough

ote)))
    .flatMap(payment -> Promise.lift(() -> legacyCreateOrder(payment)));

Now the pipeline is visible. You can see the steps, test them individually, and reason about the flow.

Peel one step deeper

Take the hottest lift() and expand it:

private Promise checkInventory(OrderRequest request) {
    return Promise.all(
            Promise.lift(() -> legacyCheckWarehouse(request)),
            Promise.lift(() -> legacyCheckSupplier(request))
        )
        .map(this::combineAvailability);
}
  • The outer call is now clean JBCT.
  • The inner calls are still wrapped.
  • Tests pass at every step.

Stop anywhere – mixed JBCT and legacy code works fine. The remaining lift() calls mark exactly where legacy code lives. When they’re all gone, you have a clean slice. There’s no deadline; each peeling step delivers value on its own.

The full migration walkthrough covers the complete path – from initial wrapping through fault tolerance to clean JBCT code.

Why slice development is different

Traditional micro‑service development is a negotiation with frameworks:

  • You learn their abstractions, lifecycle hooks, configuration DSLs, annotation model, error‑handling conventions, and testing utilities.
  • The framework becomes the center of gravity; your business logic orbits around it.

Slices invert this.

  • Business logic is the center.
  • The interface defines the contract.
  • The factory method declares dependencies.
  • The implementation is a lambda.

Everything else – serialization, routing, scaling, failover, configuration – is the runtime’s problem.

“You don’t learn a framework. You write Java interfaces and implement them.”

Two rules. The rest is just your domain.

No framework. No pain.

Resources

  • Pragmatica Aether – distributed Java runtime
  • GitHub Repository – [source code]
  • Slice Development Guide – full reference
0 views
Back to Blog

Related posts

Read more »

Testing, is it the egg or the chicken?

!Cover image for Testing, is it the egg or the chicken?https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%...