LLD Dojo

Java Bridge · J5

Interfaces vs abstract classes — default methods, and where state is allowed to live

The idea

What a type can do, against what two types share

The logger has two destination contracts, each one method: Appender.write(String) and Formatter.format(LogRecord). Say you want a running line count on both the console destination and the in-memory one. The move you already know is a base class holding the counter, so make Appender an abstract class with a written field. Two subclasses, one field, nothing duplicated.

Two things break. Demo.java registers its destinations as lambdas, and a lambda needs an interface:

error: incompatible types: Appender is not a functional interface
        Appender console = line -> System.out.println(line);

Then the JSON destination arrives, supplying both its own sink and its own format, so it has to be an Appender and a Formatter at once:

error: '{' expected
final class JsonDestination extends CountingAppender, Formatter {
                                                    ^

The caret is on the comma. There is one extends, and the counter spent it. No virtual bases, no virtual inheritance, no base-constructor ordering along two paths — the grammar refused before any of that could come up.

So the contracts are interfaces, and the counter lives in one abstract class that implements one of them. JsonDestination implements Appender, Formatter, every lambda compiles, and CountingAppender exists only because it owns a field.

The version you can run in ten seconds: name what the two implementations share. A field means an abstract class. Code with no field means a default method or a static helper. Nothing at all still means an interface, because the contract is all that is left to write. The corpus has 87 interfaces and 6 abstract classes, five of the six being exception bases.


Coming from C++

From C++ — the same two contracts, and the choice C++ never made you make

corpus/logger/contract/ has four interfaces: Appender, Formatter, Clock, LoggerApi. In C++ all four would be classes with pure virtual methods, and you would never have thought about it twice. Java splits that one construct into two, and the split is not cosmetic: one of them can hold a field and the other cannot.

The pure abstract base class translates exactly

C++

class Formatter {
public:
    virtual ~Formatter() = default;
    virtual std::string format(const LogRecord& record) const = 0;
};

Java

public interface Formatter {
    String format(LogRecord record);
}

Nothing is lost in that translation. No virtual, because every instance method already dispatches that way. No public, because an interface member has no other option. No virtual destructor, because there is nothing to destroy. The one word that carries information is interface, and it promises what the C++ version only documented: there are no fields here, so no implementor inherits any state.

Formatter in this lesson is copied from the contract unchanged, and it is the case where the decision takes no thought. Zero state, zero shared code, one method. That is an interface.

Where it stops being a translation

Now a second requirement. The console destination and the in-memory one both want a running count of lines taken. In C++ you put the counter in a base class and move on:

class CountingAppender : public Appender {
public:
    void write(const std::string& line) final {
        ++written_;
        doWrite(line);
    }
    int written() const { return written_; }

protected:
    virtual void doWrite(const std::string& line) = 0;

private:
    int written_ = 0;
};

Java has that shape, and it is an abstract class rather than an interface:

public abstract class CountingAppender implements Appender {

    private int written;

    @Override
    public final void write(String formattedLine) {
        written++;
        doWrite(formattedLine);
    }

    protected abstract void doWrite(String formattedLine);

    public final int written() {
        return written;
    }
}

Read the class declaration as the whole answer to the lesson's question. implements Appender is the contract, and abstract class is there for one line only: private int written. Delete the counter and this file has no reason to exist.

Two mechanical points that match C++ and one that does not. Instantiating it is refused:

M2.java:8: error: CountingAppender is abstract; cannot be instantiated
        CountingAppender c = new CountingAppender();
                             ^
1 error

A subclass that forgets the hook is refused, and the message names the method:

M3.java:4: error: ListAppender is not abstract and does not override abstract method doWrite(String) in CountingAppender
final class ListAppender extends CountingAppender {
      ^
1 error

The one that does not match: final on write here means what final means on a C++ virtual override, and there is no override keyword to pair it with. @Override is an annotation the compiler checks, not a modifier, and it is optional. Leave it off and the code still works; misspell the method name with it present and you get an error instead of a silent second method.

One extends, many implements

Then the JSON destination arrives. It knows what its own lines should look like and where they go, so it wants to be an Appender and a Formatter at the same time. MiniLogger.addAppender takes one of each, and the test passes the same object twice.

In C++ this is two base classes and you are done. Here:

Three.java:9: error: '{' expected
final class JsonDestination extends CountingAppender, Formatter {
                                                    ^
1 error

The caret is on the comma, and the message is about grammar rather than design. There is no production for a second superclass, so the parser stops looking for a type and starts looking for the class body. It is the least informative error in this lesson and the one you are most likely to hit. Read '{' expected after a comma as "two supertypes, and one of them is a class".

What compiles is one class and any number of interfaces:

public final class JsonDestination implements Appender, Formatter {

Main block 6 prints what that produced:

  JsonDestination's superclass   : Object, interfaces it implements: 2

Here is the part worth being deliberate about. Diamond inheritance, virtual bases, virtual public Base, base constructor ordering along two paths, one shared subobject or two: none of that has a Java equivalent. None of it is a question you can be asked about a Java design either. A field belongs to exactly one class in the chain, because there is only ever one chain. The cost is that you get one extends and have to choose what to spend it on, and the counter above spent it.

default methods, and the reason they exist

Curveball 01 asks for destinations that do not block the caller, plus one call that waits for them to catch up (corpus/logger/curveballs/01-async-appenders/). A destination that holds lines needs a way to be told to hand them over. So Appender grows a second method.

Add it the obvious way and count the damage:

public interface Appender {
    void write(String formattedLine);
    void flush();
}

Against one class implementing the interface and the two lambdas from Demo.java:

ConsoleAppender.java:1: error: ConsoleAppender is not abstract and does not override abstract method flush() in Appender
public final class ConsoleAppender implements Appender {
             ^
Demo.java:7: error: incompatible types: Appender is not a functional interface
        Appender console = line -> System.out.println(line);
                           ^
    multiple non-overriding abstract methods found in interface Appender
Demo.java:8: error: incompatible types: Appender is not a functional interface
        Appender audit = auditTrail::add;
                         ^
    multiple non-overriding abstract methods found in interface Appender
3 errors

Three errors from adding one method, and the second and third are the interesting pair. A lambda can only stand in for an interface with exactly one abstract method. Add a second and every lambda and method reference written against that interface stops compiling, wherever it lives. In the real corpus that is Demo.java and every appender in every hidden test suite.

Now the same method as a default:

    default void flush() {
    }

All three errors go away, and nothing else changes. A default method has a body, so an implementor that never heard of flush inherits a working one. A default does not count towards the single abstract method rule either, so the interface stays usable as a lambda. That is what default was added to the language for in Java 8: Collection gained stream(), forEach() and removeIf() without breaking every Collection implementation on earth.

Main block 4 shows the result in one line each. One destination overrides flush and does work; the other two inherit the empty body and are unharmed:

--- 4. flush(): one override, three inherited no-ops
  before flushAll, buffered has : 1 pending, 0 delivered
  immediate already has         : [[WARN] retrying a flaky request]
  after flushAll, buffered has  : 0 pending, 1 delivered
  delivered                     : [[WARN] retrying a flaky request]
  the lambda survived flushAll  : [[WARN] retrying a flaky request]

The threshold for a default

Two reasons are good, and there is no third.

  1. Backward compatibility. The interface is published, implementors exist that you do not control, and the new method has an honest do-nothing or derive-it-from-the-others answer. flush() qualifies: a destination holding nothing genuinely has nothing to flush.
  2. A convenience overload over the abstract method. writeAll(List<String>) is a loop calling write, and it belongs on the interface because a lambda implementor cannot declare it. The whole body is below, and note that it touches no field.
    default void writeAll(List<String> lines) {
        for (String line : lines) {
            write(line);
        }
    }

Main block 5 runs that on a method reference, which declared nothing at all:

  a one-expression Appender took : 3 lines
  in order                       : [connected, retrying, gateway timed out]

The threshold in the other direction: a default is not a place to put shared implementation that needs state. It cannot hold any, and the next section is why. When two implementations need to share a field, the thing you want is the abstract class.

Two modifiers you might reach for are refused, both because a default is not a class method:

M1.java:3: error: modifier protected not allowed here
    protected default void flush() { }
                           ^
M1.java:4: error: modifier final not allowed here
    final default void reset() { }
                       ^
2 errors

So a default is always public and always overridable. You cannot publish one an implementor is forbidden to replace, which is a real difference from a non-virtual method on a C++ base class.

A field in an interface is not the field you meant

Try to give writeAll a counter:

interface Appender {
    int written = 0;
    void write(String line);
    default void writeAll(List<String> lines) {
        for (String line : lines) {
            written++;
            write(line);
        }
    }
}
F1.java:6: error: cannot assign a value to static final variable written
            written++;
            ^
1 error

Read the message rather than the fix. Neither static nor final appears in the declaration, and the compiler used both words. Every field declared in an interface is implicitly public static final. One value, shared by the interface and every implementor, fixed at class initialisation.

The two adjacent mistakes, in case you meet them first:

F2.java:2: error: modifier private not allowed here
    private int written = 0;
                ^
1 error
F3.java:2: error: = expected
    int written;
               ^
1 error

A constant with no value has nowhere to get one, so leaving the initialiser off is a syntax error rather than a field. Main block 6 prints what a legitimate interface constant becomes:

  declared  int MAX_LINE_CHARS = 4096;
  really is public static final int
  declared  void write(String formattedLine);
  really is public abstract
  declared  default void flush() { }
  really is public, isDefault=true
  Appender: 1 field(s) declared, 0 of them per-instance
  CountingAppender: 1 field(s) declared, 1 of them per-instance

Those last two lines are the lesson in six words. The interface declares a field and has none per instance; the abstract class declares one and has one.

Where an interface constant is defensible, and where it is not. MAX_LINE_CHARS is a bound every destination is held to and none of them may change, so publishing it on the contract is honest, and MiniLogger.offer reads it. Anything a caller might want to configure per instance is the wrong shape for this, because there is exactly one of it for the whole program. Enums and constructor parameters are where the configurable version goes, which is J10 and J4.

The one diamond that survived

Two interfaces, both with a default of the same signature, one class implementing both:

interface Appender  { default String describe() { return "appender";  } }
interface Formatter { default String describe() { return "formatter"; } }

final class JsonDestination implements Appender, Formatter { … }
D1.java:11: error: types Appender and Formatter are incompatible;
final class JsonDestination implements Appender, Formatter {
      ^
  class JsonDestination inherits unrelated defaults for describe() from types Appender and Formatter
1 error

Bodies can now be inherited from two places, so ambiguity is back. Java's answer is that there is no resolution rule at all: it is a compile error, and you write the answer yourself.

    @Override
    public String describe() {
        return Appender.super.describe() + "+" + Formatter.super.describe();
    }

Appender.super.describe() is the one syntax in this lesson with no C++ counterpart. Compare it with virtual inheritance: no dominance rules, no most-derived-override table, no silent winner. Two bodies means you name which one, in a method you had to write.

This is also an argument for keeping default methods rare. Every one you add is a body somebody can collide with, and a single-abstract-method interface can never collide with anything.

When the answer really is an abstract class

The shape when you want a contract and shared state at once is both, in two files:

public interface Appender { void write(String formattedLine); }

public abstract class CountingAppender implements Appender { private int written; … }

public final class ListAppender extends CountingAppender { … }
public final class BufferedAppender implements Appender { … }

Note BufferedAppender in that list. It implements the interface directly, shares nothing with the counter, and holds two fields of its own. A hierarchy with only the abstract class in it would have forced it under a base class that had nothing to give it.

How often this comes up, counted. Across the 20 corpus problems there are 87 interfaces and 6 abstract classes. Five of the six are exception bases, where the shared state is the message and cause, and the set of subclasses is closed. The remaining one is corpus/food-ordering/reference/src/OrderStage.java, a template method with two final fields and a final enter() that calls down into two hooks. Its own javadoc argues the case, and it is worth reading as the shape of a justified abstract class.

So the honest summary is that most LLD problems never need one. Two conditions have to hold together. A second implementation already exists and shares a field with the first. And the sequence around that field must not be reorderable by a subclass. Shared code with no shared state is a static helper method or a default, and shared state with one implementation is one class.

The delta table

C++Java
class with only pure virtual methodsinterface, and the translation loses nothing
pure virtual methodordinary interface method; abstract and public are implicit
base class with a data memberabstract class; an interface cannot hold instance state
multiple inheritance of implementationone extends, and it is spent as soon as you use it
multiple inheritance of pure interfacesany number of implements, no restriction
virtual inheritance, virtual basesno equivalent, and no question you can be asked
diamond ambiguity on data membersimpossible; a field belongs to one class in one chain
diamond ambiguity on behaviourpossible, but only between two defaults, and it is an error
Base::method() to disambiguateAppender.super.describe(), interfaces only
adding a method to a published base classadding an abstract method breaks every implementor; a default does not
non-virtual method on a base classno interface equivalent; final default is refused
static const member in a baseany interface field, implicitly public static final
virtual on every overridable methodnothing; every instance method already dispatches virtually
override keyword, checked@Override annotation, optional, checked when present
final on an overridefinal on the method, same meaning
a lambda converts to any callablea lambda needs an interface with exactly one abstract method
protected data memberlegal, and protected on an interface member is refused

Two habits that will cost you time today

Reaching for an abstract class because the C++ base class was free. In C++ a base class costs nothing you will feel, because you can always add another. Here the first abstract class in a hierarchy spends the only extends every subclass has, and you find that out later, when a subclass turns out to need a second role. Appender was a one-method interface for exactly this reason, and it is why 03-rolling-appender costs the reference zero lines.

Writing an interface with one implementation because it looks like good separation. The corpus justifies Appender with a real second implementation and a third in a curveball. An interface with one implementor and no second in sight is what STANDARD v1.0 penalises under the over-engineered tag, and B1's when-not.md is where that argument is made in full.

What this lesson deliberately leaves out

There is a third option for a closed set of variants: a sealed interface with record implementations, which lets the compiler check that a switch covered every case. That is the modern answer when you know all the implementors and want to keep knowing. It is J11, along with record, var and switch patterns, and choosing it needs those first.


Worked walkthrough

NOTES — eleven files, one abstract class, and the field that justifies it

Run it first

From the directory holding the sources:

..\..\..\.toolchain\jdk-21\bin\javac.exe -d out *.java
..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main

javac -Xlint:all on these eleven files prints nothing and exits 0. Real output, verbatim:

--- 1. four implementors of one interface, none of them related
    console | connected
  lambda and method ref captured : [connected, connected]
  the lambda's class is synthetic: true (no source file exists for it)
  the method ref is synthetic too: true
  the other two are             : ConsoleAppender and JsonDestination
--- 2. the shared field, maintained in exactly one place
    console | first
    console | second
  console.written()             : 2
  list.written()                : 3
  list.lines()                  : [a, b, c]
  one count per object, not one shared by the class
--- 3. one object registered as both halves of a destination
  json.lines().size()           : 1
  {"at":"2026-08-18T09:15:00Z","level":"INFO","thread":"main","msg":"connected"}
  is an Appender                : true
  is a Formatter                : true
--- 4. flush(): one override, three inherited no-ops
  before flushAll, buffered has : 1 pending, 0 delivered
  immediate already has         : [[WARN] retrying a flaky request]
  after flushAll, buffered has  : 0 pending, 1 delivered
  delivered                     : [[WARN] retrying a flaky request]
  the lambda survived flushAll  : [[WARN] retrying a flaky request]
--- 5. writeAll(): a body on an interface, running on a lambda
  a one-expression Appender took : 3 lines
  in order                       : [connected, retrying, gateway timed out]
  MAX_LINE_CHARS                 : 4096
  5000-char message arrived as   : 4096 chars
--- 6. what javac made of the members we declared
  declared  int MAX_LINE_CHARS = 4096;
  really is public static final int
  declared  void write(String formattedLine);
  really is public abstract
  declared  default void flush() { }
  really is public, isDefault=true
  Appender: 1 field(s) declared, 0 of them per-instance
  CountingAppender: 1 field(s) declared, 1 of them per-instance
  JsonDestination's superclass   : Object, interfaces it implements: 2

Four lines in there are the lesson. The two field tallies in block 6, both true lines in block 3, and after flushAll, buffered has : 0 pending, 1 delivered in block 4.

Then look at out/:

Appender.class          CountingAppender.class  Level.class      Main.class
BufferedAppender.class  Formatter.class         ListAppender.class
ConsoleAppender.class   JsonDestination.class   LogRecord.class
MiniLogger$Registration.class  MiniLogger.class

Twelve class files from eleven sources, and the extra one is MiniLogger$Registration, the nested record. No $1, $2, $3: the two lambdas in Main are not anonymous classes and produce no files. They are spun up at run time from an invokedynamic call site, which is why block 1 reports them as synthetic and why they have no name worth printing.


Formatter.java — the case with nothing to decide

public interface Formatter {
    String format(LogRecord record);
}

Copied from the contract unchanged, and it is the whole file. No state, no shared code, one method, several implementations. Making this an abstract class would take away every lambda that implements it. Main block 4 has one, record -> "[" + record.level() + "] " + record.message(), and Demo.java in the corpus has two more.

The guarantee interface gives here that a C++ pure abstract base class does not: no implementor inherits any state from it, ever, because the language forbids the field. In C++ the same promise is a convention that the next person to edit the header can break by adding a member.


Appender.java — three kinds of member, three different reasons

    int MAX_LINE_CHARS = 4096;

Implicitly public static final, and block 6 prints those three words back. One value for the whole program, readable as Appender.MAX_LINE_CHARS from anywhere.

What it guarantees: no destination can be handed a line longer than this, because MiniLogger.offer truncates against it and every destination goes through offer. What breaks without the constant: the bound has to be repeated at every call site, and the fifth one gets it wrong.

What it cannot be: per-instance. There is no way to give one destination a different limit here. The moment you want that, the value belongs in a constructor parameter, and that is J4.

    void write(String formattedLine);

The only abstract method, and keeping it the only one is a design constraint rather than an accident. Block 6 reports it as public abstract without either word being written.

One abstract method is what makes Appender a functional interface, so `line -> System.out.println(line) and captured::add` are both legal destinations. Add a second abstract method and every lambda in the corpus stops compiling. from-cpp.md has the three errors.

    default void flush() {
    }

The empty body is the answer, not a placeholder. A destination that holds nothing has nothing to hand over, so doing nothing is correct for it.

What this guarantees: MiniLogger.flushAll can call flush() on every registration without knowing what any of them is. What breaks if this is abstract instead: three compile errors across two files that worked yesterday, including both lambdas. What breaks if it is not on the interface at all: flushAll needs instanceof BufferedAppender, and it has to be edited for the second kind of buffered destination.

    default void writeAll(List<String> lines) {
        for (String line : lines) {
            write(line);
        }
    }

It calls write and touches no field, which is the test for whether a body belongs on an interface. A default has no state available to it: the only fields in scope are the interface's own, and those are static and final.

Note it calls write rather than doing the work itself. That is what keeps CountingAppender's counter correct through this path, and GapTest asserts it: list.writeAll(List.of("a", "b")) leaves written() at 2. A version that reached past write would deliver the lines and lose the count. That bug shows up as a metric being wrong, not as a log line going missing, so it gets diagnosed a long way from here.


CountingAppender.java — the abstract class, and its one reason to exist

public abstract class CountingAppender implements Appender {

    private int written;

This field is the entire argument for the class. An interface cannot declare an instance field, so a shared mutable count has nowhere else to live. Block 6 states it in two lines: Appender has one field and none of it is per-instance, CountingAppender has one and it is.

implements Appender and not "instead of": the contract stays on the interface, so BufferedAppender can satisfy it without coming near this class.

    @Override
    public final void write(String formattedLine) {
        written++;
        doWrite(formattedLine);
    }

final is the load-bearing word. The invariant: after n calls to write on an instance, written() is n, and all n lines reached doWrite in order.

Drop final and a subclass can override write. Then some lines are counted and some are not, and nothing warns you. The symptom is a count that disagrees with the log, and the count is usually what somebody is alerting on.

This is the template method shape, and it is the reason to prefer an abstract class over a default: final on a default method is refused with error: modifier final not allowed here. An interface cannot stop an implementor replacing the sequence.

    protected abstract void doWrite(String formattedLine);

protected and abstract together say: subclasses must answer this, nobody else may call it. abstract moves "did you decide where lines go?" from a review comment to a compile error:

M3.java:4: error: ListAppender is not abstract and does not override abstract method doWrite(String) in CountingAppender
final class ListAppender extends CountingAppender {
      ^
1 error

Give it a body instead and that error disappears along with the guarantee. A subclass that forgot would then silently discard every line.

Not thread-safe, and this matters against the real contract. corpus/logger/contract/Appender.java says write may be called for one instance from many threads at once. written++ is a read, an add and a write, so two threads can lose an increment between them. Nothing in this lesson takes a lock, because the fix belongs to J12. Say that out loud if you write this class in a round; an unqualified "I keep a count" invites the follow-up.


ConsoleAppender.java and ListAppender.java — the second implementation

public final class ConsoleAppender extends CountingAppender {
    @Override
    protected void doWrite(String formattedLine) {
        System.out.println("    console | " + formattedLine);
    }
}

Two lines of its own, and that ratio is the argument for the base class. One subclass would not be. With a single implementation the counter belongs inside that class, and the abstract class is a file to open for nothing.

    /** A copy, so a caller cannot add lines the counter never saw. */
    public List<String> lines() {
        return List.copyOf(lines);
    }

The copy keeps written() and lines() consistent. Hand out the live ArrayList and a caller can add a line the counter never saw, so written() and lines().size() disagree. That is A7's subject and it costs one method call here.

final on both classes: no third level, so nothing can override doWrite again. One abstract class with concrete leaves is a shape you can defend. A three-level hierarchy is one somebody will ask you to justify.


BufferedAppender.java — the implementor that proves the interface

public final class BufferedAppender implements Appender {

    private final List<String> pending = new ArrayList<>();
    private final List<String> delivered = new ArrayList<>();

implements Appender, not extends CountingAppender, and that is the point of the file. It has two fields of its own and no use for a counter. Under a mandatory base class it would inherit a field it does not want and spend its one extends on it.

This is the shape curveball 03 asks for. corpus/logger/curveballs/03-rolling-appender/ wants a destination that starts a new file every n lines, and the reference absorbs it in zero lines, measured in that curveball's PATCH.md. Zero, because a destination is one method and its own state is nobody else's business.

    @Override
    public void flush() {
        delivered.addAll(pending);
        pending.clear();
    }

The clear() is what makes flush repeatable. The invariant: after flush() returns, pending() is 0, and every line written before the call is in delivered() in order.

Drop the clear() and a second flush delivers everything a second time. GapTest calls flush twice for exactly that reason. Duplicate log lines are the kind of bug that gets diagnosed as a retry loop somewhere else entirely.

This is the one override of a default in the lesson, and it is one of five destinations. That ratio is the honest case for flush() being a default rather than abstract.


JsonDestination.java — one object, both parameters

public final class JsonDestination implements Appender, Formatter {

One class declaration, two contracts, and it compiles because both are interfaces. Block 3 prints is an Appender : true and is a Formatter : true for the same instance, and block 6 confirms its superclass is Object.

Turn either interface into an abstract class and this line fails on the comma, with error: '{' expected. The class had no superclass to spare, and it did not need one.

        logger.addAppender(json, Level.INFO, json);

The same reference passed twice, to two parameters of two different types. That is what an interface buys and a base class cannot. A type can be several things at once, as long as none of those things owns a field in it.

Worth knowing where the limit is. This is fine when the two roles are genuinely one decision, which is the case here: this destination's format exists only for this destination. When the format is reusable, keep it separate. Demo.java in the corpus does, and registers one formatter against two appenders.

    @Override
    public String format(LogRecord record) {

No field read and no field written, so format is a pure function of the record. That is what lets MiniLogger call it once per destination in any order, and what makes the same record safe to hand to every destination without a copy.


MiniLogger.java — the class that knows two type names

    private record Registration(Appender appender, Level threshold, Formatter formatter) {
    }

Two interfaces and an enum, and no concrete destination class named anywhere. This is corpus/logger/reference/src/Registration.java cut down, and the reason it can be a nested private record is that nothing outside MiniLogger needs it.

What that guarantees: adding a destination kind needs no edit here. What breaks if Registration holds CountingAppender instead of Appender: BufferedAppender and every lambda stop being registrable, and the file has to be edited for each new kind.

        if (line.length() > Appender.MAX_LINE_CHARS) {
            line = line.substring(0, Appender.MAX_LINE_CHARS);
        }

The constant is read from the interface, so the bound and the contract cannot drift apart. Block 5 logs a 5000-character message and the destination receives 4096.

    public void flushAll() {
        for (Registration registration : registrations) {
            registration.appender().flush();
        }
    }

This loop is the reason flush() had to be on Appender. The only static type available here is Appender, and this method cannot ask a registration what it really is.

What breaks without the default: this loop needs instanceof BufferedAppender, and then a cast, and then an edit for every future kind of destination that holds lines. Block 4 shows all three destinations going through the same loop, with one of them doing work and two of them doing nothing.


The threshold, stated so it is decidable

Under interview time pressure, the question is only ever which of two things you need. Ten seconds:

Name what the implementations share. A field means an abstract class. A signature means an interface. Nothing shared means an interface.

The longer form, in the order the questions matter:

  1. Is there anything to share at all? One method, no state: interface, and stop. This is Appender, Formatter, Clock and LoggerApi, which is four of the four contracts in this corpus problem.
  2. Is the shared thing a field? Yes: abstract class, and only the field goes in it. This is CountingAppender, and its one field is the whole justification.
  3. Is the shared thing code with no state? A static helper method, or a default if every implementor should have it. writeAll is the second case.
  4. Must the sequence be unbreakable? An abstract class with a final method calling abstract hooks. final default does not exist, so an interface cannot make this promise.
  5. Do you know every implementor and want the compiler to keep checking? A sealed interface, which is J11.

And the cost of getting it wrong in each direction. An unnecessary abstract class spends the one extends its subclasses have, and you find out when one of them needs a second role. An interface with one implementation and no second in sight is what the over-engineered tag is for.


What an interviewer is measuring

Not whether you know the keywords. Two things:

Whether you can say why the state is where it is. "The count is a field, and only a class can hold a field, so the count lives in CountingAppender and the contract stays on Appender" is a design sentence. "I used an abstract class for reuse" is not, and it invites a follow-up you have not prepared.

Whether your interfaces have more than one implementation. Appender has four classes behind it in this lesson plus two lambdas. In the corpus problem itself exactly one class implements it, RollingAppender in curveballs/03-rolling-appender/tests/, and every other destination there is a lambda or a method reference. An interface with one abstract method is what allows that, and it is what makes this a seam rather than a layer.

The measured version: curveball 03 costs the reference 0 lines and curveball 02 costs it 10, from each one's budget.json, and neither of them reaches past Logger.java.


Worked source

The 11 files of the worked design

Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.

worked/src/Appender.java60 lines

// Appender.java
//
// corpus/logger/contract/Appender.java declares exactly one method, void write(String).
// This version is that interface after two rounds of change, so every member here is an
// answer to a question the base contract had not been asked yet.
//
// Single-threaded on purpose. The real contract says write may be called from many threads at
// once; nothing in this lesson takes a lock, and the locking is J12's subject.
import java.util.List;

public interface Appender {

    /**
     * The longest line any destination is asked to write.
     *
     * There is no `static` and no `final` here, and it has both. An interface has no instance
     * fields, so every field it declares is implicitly public static final, and Main prints
     * what reflection makes of this line. Writing `private int written = 0;` instead is
     * `error: modifier private not allowed here`, and leaving the `= 4096` off is
     * `error: = expected` — a constant with no value has nowhere to get one.
     */
    int MAX_LINE_CHARS = 4096;

    /**
     * Writes one already-formatted line. The only abstract method on this interface, which is
     * what keeps `line -> System.out.println(line)` a legal Appender.
     */
    void write(String formattedLine);

    /**
     * Catches this destination up on anything it is holding.
     *
     * Added for the async requirement in curveballs/01-async-appenders. It is `default` and
     * empty because the alternative was breaking every implementor at once: as a plain
     * abstract method it produced three errors across two files that had compiled the day
     * before, including both lambdas in the corpus Demo. from-cpp.md quotes them.
     *
     * A destination that holds nothing has genuinely nothing to do here, so the empty body is
     * the right answer rather than a placeholder. BufferedAppender is the one that overrides.
     */
    default void flush() {
    }

    /**
     * Writes several lines in order, one call to write each.
     *
     * A convenience overload, not a compatibility patch: it exists so a caller with a batch
     * does not write the loop, and it is on the interface rather than on a helper class so
     * that every implementor has it — including lambdas, which cannot declare methods at all.
     *
     * It calls write and holds no state, which is the test for whether a default belongs
     * here. A default cannot hold state: the counter it would need is a field, and the field
     * would be static and final and shared by every implementor.
     */
    default void writeAll(List<String> lines) {
        for (String line : lines) {
            write(line);
        }
    }
}

worked/src/BufferedAppender.java43 lines

// BufferedAppender.java
//
// The implementor that proves Appender has to be an interface. It shares nothing with
// CountingAppender — no counter, no template — and it holds two fields of its own, so a
// common base class would have had nothing to give it and one `extends` to take.
//
// This is also the destination flush() was added for: it holds lines until somebody asks.
import java.util.ArrayList;
import java.util.List;

public final class BufferedAppender implements Appender {

    private final List<String> pending = new ArrayList<>();
    private final List<String> delivered = new ArrayList<>();

    @Override
    public void write(String formattedLine) {
        pending.add(formattedLine);
    }

    /**
     * The one override of the default in this lesson.
     *
     * The invariant callers rely on: after flush() returns, pending() is 0 and every line
     * written before the call is in delivered(), in the order it was written. Drop the
     * `pending.clear()` and a second flush delivers everything twice.
     */
    @Override
    public void flush() {
        delivered.addAll(pending);
        pending.clear();
    }

    /** Lines written but not yet handed over. */
    public int pending() {
        return pending.size();
    }

    /** Lines this destination has genuinely delivered. A copy. */
    public List<String> delivered() {
        return List.copyOf(delivered);
    }
}

worked/src/ConsoleAppender.java12 lines

// ConsoleAppender.java
//
// Writes to stdout, and nothing else. Two lines of its own, because the counting is in the
// class above it and the contract is in the interface above that.

public final class ConsoleAppender extends CountingAppender {

    @Override
    protected void doWrite(String formattedLine) {
        System.out.println("    console | " + formattedLine);
    }
}

worked/src/CountingAppender.java40 lines

// CountingAppender.java
//
// The one abstract class in the lesson, and it exists for one reason: `written` is a field,
// and an interface cannot hold one. ConsoleAppender and ListAppender extend it.
//
// Read the split as: Appender says what a destination can do, CountingAppender shares an
// implementation between two destinations that happen to want the same counter.
//
// Single-threaded on purpose. `written++` is three operations, not one, and the real contract
// allows concurrent calls to write. J12 is where that gets fixed.

public abstract class CountingAppender implements Appender {

    /**
     * The whole justification for this class being a class. Private, so the count cannot be
     * adjusted from outside the one method that maintains it.
     */
    private int written;

    /**
     * `final`, so a subclass cannot count some of its lines and not others.
     *
     * The invariant: after n calls to write on this instance, written() is n. A subclass able
     * to override write could break that silently, and the failure would look like a missing
     * log line rather than a missing increment.
     */
    @Override
    public final void write(String formattedLine) {
        written++;
        doWrite(formattedLine);
    }

    /** Where the line actually goes. The only thing a subclass has left to decide. */
    protected abstract void doWrite(String formattedLine);

    /** How many lines this destination has taken. */
    public final int written() {
        return written;
    }
}

worked/src/Formatter.java11 lines

// Formatter.java — copied unchanged from corpus/logger/contract/Formatter.java, javadoc trimmed.
//
// One method, no state, no memory of the record before it. This is the interface a C++
// developer would have written as a pure abstract base class, and the translation is exact:
// there is nothing here for an abstract class to add.

public interface Formatter {

    /** Renders one record as the exact text a destination will write. Never null. */
    String format(LogRecord record);
}

worked/src/JsonDestination.java35 lines

// JsonDestination.java
//
// One object that is both halves of a destination: it decides what its lines look like and it
// decides where they go. MiniLogger registers it twice over in one call —
// addAppender(json, INFO, json) — because the two parameters ask for two interfaces and this
// class implements both.
//
// Turn either Appender or Formatter into an abstract class and this file stops compiling.
import java.util.ArrayList;
import java.util.List;

public final class JsonDestination implements Appender, Formatter {

    private final List<String> lines = new ArrayList<>();

    /** The Formatter half. No state read, no state written — a pure function of the record. */
    @Override
    public String format(LogRecord record) {
        return "{\"at\":\"" + record.timestamp()
                + "\",\"level\":\"" + record.level()
                + "\",\"thread\":\"" + record.threadName()
                + "\",\"msg\":\"" + record.message() + "\"}";
    }

    /** The Appender half, which never looks at what format() produced. */
    @Override
    public void write(String formattedLine) {
        lines.add(formattedLine);
    }

    /** What this destination holds. A copy. */
    public List<String> lines() {
        return List.copyOf(lines);
    }
}

worked/src/Level.java13 lines

// Level.java — copied unchanged from corpus/logger/contract/Level.java, javadoc trimmed.
//
// Here so this lesson compiles on its own. Declaration order is severity order.

public enum Level {

    DEBUG, INFO, WARN, ERROR, FATAL;

    /** Inclusive: WARN.atLeast(WARN) is true. */
    public boolean atLeast(Level threshold) {
        return this.compareTo(threshold) >= 0;
    }
}

worked/src/ListAppender.java21 lines

// ListAppender.java
//
// Keeps every line for a test to read back. The second implementation of CountingAppender,
// which is what makes the shared counter worth a class rather than worth inlining once.
import java.util.ArrayList;
import java.util.List;

public final class ListAppender extends CountingAppender {

    private final List<String> lines = new ArrayList<>();

    @Override
    protected void doWrite(String formattedLine) {
        lines.add(formattedLine);
    }

    /** A copy, so a caller cannot add lines the counter never saw. */
    public List<String> lines() {
        return List.copyOf(lines);
    }
}

worked/src/LogRecord.java16 lines

// LogRecord.java — copied unchanged from corpus/logger/contract/LogRecord.java, javadoc trimmed.
//
// One log event. A value: nothing about it can be mutated after construction, so the same
// instance is handed to every destination that receives it.
import java.time.Instant;
import java.util.Objects;

public record LogRecord(Instant timestamp, Level level, String message, String threadName) {

    public LogRecord {
        Objects.requireNonNull(timestamp, "timestamp");
        Objects.requireNonNull(level, "level");
        Objects.requireNonNull(message, "message");
        Objects.requireNonNull(threadName, "threadName");
    }
}

worked/src/MiniLogger.java93 lines

// MiniLogger.java
//
// corpus/logger/reference/src/Logger.java, cut down to the parts this lesson is about. The
// clock is a fixed Instant rather than an injected Clock so the output is reproducible; the
// real contract injects one, and C3 is where that argument lives.
//
// Note what this class knows about its destinations: two interface names and nothing else. It
// has never heard of CountingAppender, BufferedAppender or JsonDestination.
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class MiniLogger {

    /** One destination: where lines go, the minimum level it accepts, and how it renders. */
    private record Registration(Appender appender, Level threshold, Formatter formatter) {
    }

    private final List<Registration> registrations = new ArrayList<>();
    private final Instant fixedTime;

    public MiniLogger(Instant fixedTime) {
        this.fixedTime = Objects.requireNonNull(fixedTime, "fixedTime");
    }

    /** Both parameters are interfaces, which is why one object can satisfy both. */
    public void addAppender(Appender appender, Level threshold, Formatter formatter) {
        registrations.add(new Registration(
                Objects.requireNonNull(appender, "appender"),
                Objects.requireNonNull(threshold, "threshold"),
                Objects.requireNonNull(formatter, "formatter")));
    }

    public void log(Level level, String message) {
        LogRecord record = new LogRecord(
                fixedTime, level, message, Thread.currentThread().getName());
        for (Registration registration : registrations) {
            if (level.atLeast(registration.threshold())) {
                offer(registration, record);
            }
        }
    }

    /** Exactly log(Level.DEBUG, message). */
    public void debug(String message) {
        log(Level.DEBUG, message);
    }

    /** Exactly log(Level.INFO, message). */
    public void info(String message) {
        log(Level.INFO, message);
    }

    /** Exactly log(Level.WARN, message). */
    public void warn(String message) {
        log(Level.WARN, message);
    }

    /** Exactly log(Level.ERROR, message). */
    public void error(String message) {
        log(Level.ERROR, message);
    }

    /** One destination, one record. A destination that throws is skipped, never fatal. */
    private void offer(Registration registration, LogRecord record) {
        String line;
        try {
            line = registration.formatter().format(record);
        } catch (RuntimeException formatterFailed) {
            return;
        }
        if (line.length() > Appender.MAX_LINE_CHARS) {
            line = line.substring(0, Appender.MAX_LINE_CHARS);
        }
        try {
            registration.appender().write(line);
        } catch (RuntimeException appenderFailed) {
            // a broken destination must not silence the others
        }
    }

    /**
     * Catches every destination up. This loop is the reason flush() had to go on Appender
     * rather than on the two classes that need it: this method holds Appender references and
     * cannot ask any of them what they really are.
     */
    public void flushAll() {
        for (Registration registration : registrations) {
            registration.appender().flush();
        }
    }
}

worked/src/Main.java145 lines

// Main.java — run this. Six blocks, each one claim from the lesson.
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

public final class Main {

    private Main() {
    }

    private static final Instant AT = Instant.parse("2026-08-18T09:15:00Z");

    public static void main(String[] args) throws Exception {
        oneInterfaceFourKindsOfImplementor();
        theFieldThatForcedAnAbstractClass();
        oneObjectTwoInterfaces();
        theDefaultThatKeptEveryoneCompiling();
        theConvenienceDefault();
        whatTheInterfaceMembersReallyAre();
    }

    private static void oneInterfaceFourKindsOfImplementor() {
        System.out.println("--- 1. four implementors of one interface, none of them related");
        List<String> captured = new ArrayList<>();

        Appender lambda = line -> captured.add(line);
        Appender methodRef = captured::add;
        Appender subclass = new ConsoleAppender();
        Appender bothRoles = new JsonDestination();

        for (Appender appender : List.of(lambda, methodRef, subclass, bothRoles)) {
            appender.write("connected");
        }
        System.out.println("  lambda and method ref captured : " + captured);
        System.out.println("  the lambda's class is synthetic: " + lambda.getClass().isSynthetic()
                + " (no source file exists for it)");
        System.out.println("  the method ref is synthetic too: " + methodRef.getClass().isSynthetic());
        System.out.println("  the other two are             : " + subclass.getClass().getSimpleName()
                + " and " + bothRoles.getClass().getSimpleName());
    }

    private static void theFieldThatForcedAnAbstractClass() {
        System.out.println("--- 2. the shared field, maintained in exactly one place");
        ConsoleAppender console = new ConsoleAppender();
        ListAppender list = new ListAppender();

        console.write("first");
        console.write("second");
        list.writeAll(List.of("a", "b", "c"));

        System.out.println("  console.written()             : " + console.written());
        System.out.println("  list.written()                : " + list.written());
        System.out.println("  list.lines()                  : " + list.lines());
        System.out.println("  one count per object, not one shared by the class");
    }

    private static void oneObjectTwoInterfaces() {
        System.out.println("--- 3. one object registered as both halves of a destination");
        MiniLogger logger = new MiniLogger(AT);
        JsonDestination json = new JsonDestination();

        logger.addAppender(json, Level.INFO, json);
        logger.info("connected");
        logger.debug("below the threshold, nobody sees it");

        System.out.println("  json.lines().size()           : " + json.lines().size());
        json.lines().forEach(line -> System.out.println("  " + line));
        System.out.println("  is an Appender                : " + (json instanceof Appender));
        System.out.println("  is a Formatter                : " + (json instanceof Formatter));
    }

    private static void theDefaultThatKeptEveryoneCompiling() {
        System.out.println("--- 4. flush(): one override, three inherited no-ops");
        MiniLogger logger = new MiniLogger(AT);
        BufferedAppender buffered = new BufferedAppender();
        ListAppender immediate = new ListAppender();
        List<String> viaLambda = new ArrayList<>();

        Formatter short_ = record -> "[" + record.level() + "] " + record.message();
        logger.addAppender(buffered, Level.INFO, short_);
        logger.addAppender(immediate, Level.INFO, short_);
        logger.addAppender(viaLambda::add, Level.INFO, short_);

        logger.warn("retrying a flaky request");
        System.out.println("  before flushAll, buffered has : " + buffered.pending() + " pending, "
                + buffered.delivered().size() + " delivered");
        System.out.println("  immediate already has         : " + immediate.lines());
        logger.flushAll();
        System.out.println("  after flushAll, buffered has  : " + buffered.pending() + " pending, "
                + buffered.delivered().size() + " delivered");
        System.out.println("  delivered                     : " + buffered.delivered());
        System.out.println("  the lambda survived flushAll  : " + viaLambda);
    }

    private static void theConvenienceDefault() {
        System.out.println("--- 5. writeAll(): a body on an interface, running on a lambda");
        List<String> captured = new ArrayList<>();
        Appender lambda = captured::add;

        lambda.writeAll(List.of("connected", "retrying", "gateway timed out"));
        System.out.println("  a one-expression Appender took : " + captured.size() + " lines");
        System.out.println("  in order                       : " + captured);

        System.out.println("  MAX_LINE_CHARS                 : " + Appender.MAX_LINE_CHARS);
        MiniLogger logger = new MiniLogger(AT);
        ListAppender sink = new ListAppender();
        logger.addAppender(sink, Level.INFO, record -> record.message());
        logger.error("x".repeat(5000));
        System.out.println("  5000-char message arrived as   : "
                + sink.lines().get(0).length() + " chars");
    }

    private static void whatTheInterfaceMembersReallyAre() throws Exception {
        System.out.println("--- 6. what javac made of the members we declared");
        Field constant = Appender.class.getField("MAX_LINE_CHARS");
        Method write = Appender.class.getMethod("write", String.class);
        Method flush = Appender.class.getMethod("flush");

        System.out.println("  declared  int MAX_LINE_CHARS = 4096;");
        System.out.println("  really is " + Modifier.toString(constant.getModifiers())
                + " " + constant.getType().getSimpleName());
        System.out.println("  declared  void write(String formattedLine);");
        System.out.println("  really is " + Modifier.toString(write.getModifiers()));
        System.out.println("  declared  default void flush() { }");
        System.out.println("  really is " + Modifier.toString(flush.getModifiers())
                + ", isDefault=" + flush.isDefault());
        System.out.println("  Appender: " + fieldTally(Appender.class));
        System.out.println("  CountingAppender: " + fieldTally(CountingAppender.class));
        System.out.println("  JsonDestination's superclass   : "
                + JsonDestination.class.getSuperclass().getSimpleName()
                + ", interfaces it implements: " + JsonDestination.class.getInterfaces().length);
    }

    /** How many fields a type declares, and how many of those are per-instance. */
    private static String fieldTally(Class<?> type) {
        Field[] declared = type.getDeclaredFields();
        long instanceFields = java.util.Arrays.stream(declared)
                .filter(f -> !Modifier.isStatic(f.getModifiers()))
                .count();
        return declared.length + " field(s) declared, " + instanceFields + " of them per-instance";
    }
}

The faded stage is not here, on purpose

In the app, the third stage of a lesson hands you the worked design with a few lines replaced by // GAP: markers, then compiles your completion and runs a JUnit suite against it. That needs javac, and a static site has no compiler — so rather than show a control that cannot work, this page stops at the worked source.

Run the app for the drill: it is the download in the header, and it works offline once unpacked.

← J4 · Construction — final fields, no initialiser lists, and static factories J6 · equals, hashCode, toString, compareTo — and the == trap →

← all lessons