Skip to content
academia.sh

Lesson 10 / 19

Data Access Abstraction

The same compiled set of callers — the object container, the service, and the entry point — runs against two separate implementations of a repository interface, without ever being recompiled. The caller's class file mentions only the interface; the same call runs without a hitch under one implementation while falling with an exception under the other, because the interface's promise and the implementation's behavior are separate, and the container does not close this gap, it only hides it.

Published

In this lesson

  • Shows that the same compiled set of classes calling the Repository interface runs against two separate implementations without ever being recompiled
  • Confirms that the caller's class file mentions only the Repository interface, no implementation class at all
  • Measures that the interface's promise and the implementation's behavior are separate, by showing that the same call runs without a hitch under one implementation while throwing an exception under the other
Contents

The previous lesson measured whether a request’s data is correct, but it never asked where that data gets written once it is processed. This lesson takes this up: it measures how the container supplies the implementation of a repository interface, and how the same caller can run against two separate implementations without ever being recompiled. The idea of a repository — keeping business logic independent of persistence details — was established in The Data Access Layer and Business Logic course and is not repeated here; this lesson’s question is separate from that: by what mechanism the container fills this interface.

The database is modeled here too: no real connection is opened, no real SQL statement is run. Repository is just an interface carrying two methods — reading a quantity, writing a quantity — and what stands behind these two methods is invisible to the caller. The only way to show how real this invisibility is, is to really run the same caller against two separate implementations; the method used in this lesson is exactly that.

In the previous lessons, “running the same behavior two ways” meant comparing an object the container built against a manually built object. This lesson carries the same principle one step further: what is compared here is not two objects, but the same calling code run against two separate implementations. This is also where an interface is truly tested — imitating a single class’s behavior is not enough; it also has to be seen whether a completely different internal structure, holding the same contract, satisfies the same calling code.

The Same Caller, Two Separate Implementations

  • WD16. This measurement stages a real classpath distinction: the interface, the calling code, and each implementation are compiled into separate directories, then brought together in different combinations at runtime. Which classpath a class has to be required in was measured in the Build, Testing and the Ecosystem course; here the same mechanism is used for the interface–implementation distinction, and the numbers from there are not repeated.
  • WD17. The Container.create method takes the implementation class’s name as a string parameter and finds it with Class.forName; this is how the caller can construct an instance without mentioning any implementation class at compile time.

The Container, Service, and Main classes are compiled only against the Repository interface — neither MemoryRepository nor ArrayRepository ever enters the compile-time classpath. These three classes are compiled exactly once, then run with two separate runtime classpaths, never recompiled: with MemoryRepository in one, ArrayRepository in the other. The only difference between the two is the third classpath component given to the run call and the implementation name passed to Main.main.

// TwoImplementations.java — the same compiled caller runs with two separate implementations without changing the interface
import java.io.*;
import java.lang.reflect.InvocationTargetException;
import java.net.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

public class TwoImplementations {

    static Path write(Path directory, String name, String source) throws IOException {
        Files.createDirectories(directory);
        Path p = directory.resolve(name + ".java");
        Files.writeString(p, source);
        return p;
    }

    static void compile(Path output, List<Path> classpath, List<Path> sources) throws IOException {
        Files.createDirectories(output);
        List<String> arg = new ArrayList<>(List.of("-d", output.toString()));
        if (!classpath.isEmpty()) {
            StringJoiner sj = new StringJoiner(File.pathSeparator);
            for (Path p : classpath) sj.add(p.toString());
            arg.add("-classpath");
            arg.add(sj.toString());
        }
        for (Path k : sources) arg.add(k.toString());
        ToolProvider compiler = ToolProvider.findFirst("javac").orElseThrow();
        StringWriter outputText = new StringWriter();
        int result = compiler.run(new PrintWriter(outputText), new PrintWriter(outputText), arg.toArray(new String[0]));
        if (result != 0) throw new RuntimeException("compilation failed: " + outputText);
    }

    static String run(List<Path> classpath, String implementationName) {
        URL[] url = new URL[classpath.size()];
        try {
            for (int i = 0; i < url.length; i++) url[i] = classpath.get(i).toUri().toURL();
            try (URLClassLoader loader = new URLClassLoader(url, TwoImplementations.class.getClassLoader().getPlatformClassLoader())) {
                Class<?> main = Class.forName("Main", true, loader);
                ByteArrayOutputStream buffer = new ByteArrayOutputStream();
                PrintStream old = System.out;
                System.setOut(new PrintStream(buffer));
                try {
                    main.getMethod("main", String[].class).invoke(null, (Object) new String[]{implementationName});
                } finally {
                    System.setOut(old);
                }
                return buffer.toString().strip();
            }
        } catch (InvocationTargetException e) {
            return "exception: " + e.getCause().getClass().getSimpleName() + ": " + e.getCause().getMessage();
        } catch (Throwable t) {
            return "exception: " + t.getClass().getSimpleName();
        }
    }

    public static void main(String[] args) throws Exception {
        Path root = Files.createTempDirectory("twoimpl");
        Path source = root.resolve("source");

        Path sInterface = write(source.resolve("interface"), "Repository",
                "public interface Repository {\n"
              + "  int findQuantity(String product);\n"
              + "  void updateQuantity(String product, int quantity);\n"
              + "}\n");

        Path sContainer = write(source.resolve("caller"), "Container",
                "public class Container {\n"
              + "  static Service create(String implementationName) throws ReflectiveOperationException {\n"
              + "    Class<?> c = Class.forName(implementationName);\n"
              + "    Repository repository = (Repository) c.getDeclaredConstructor().newInstance();\n"
              + "    return new Service(repository);\n"
              + "  }\n"
              + "}\n");
        Path sService = write(source.resolve("caller"), "Service",
                "public class Service {\n"
              + "  private final Repository repository;\n"
              + "  public Service(Repository repository) { this.repository = repository; }\n"
              + "  public String apply(String product, int change) {\n"
              + "    int current = repository.findQuantity(product);\n"
              + "    int updated = current + change;\n"
              + "    repository.updateQuantity(product, updated);\n"
              + "    return product + \": \" + current + \" -> \" + updated;\n"
              + "  }\n"
              + "}\n");
        Path sMain = write(source.resolve("caller"), "Main",
                "public class Main {\n"
              + "  public static void main(String[] args) throws Exception {\n"
              + "    Service service = Container.create(args[0]);\n"
              + "    System.out.println(service.apply(\"screw\", 5));\n"
              + "    System.out.println(service.apply(\"screw\", -2));\n"
              + "    System.out.println(service.apply(\"nail\", 3));\n"
              + "  }\n"
              + "}\n");

        Path sMemory = write(source.resolve("memory"), "MemoryRepository",
                "import java.util.*;\n"
              + "public class MemoryRepository implements Repository {\n"
              + "  private final Map<String, Integer> data = new HashMap<>();\n"
              + "  public int findQuantity(String product) { return data.getOrDefault(product, 0); }\n"
              + "  public void updateQuantity(String product, int quantity) { data.put(product, quantity); }\n"
              + "}\n");
        Path sArray = write(source.resolve("array"), "ArrayRepository",
                "public class ArrayRepository implements Repository {\n"
              + "  private final String[] names = new String[1];\n"
              + "  private final int[] quantities = new int[1];\n"
              + "  private int filled = 0;\n"
              + "  public int findQuantity(String product) {\n"
              + "    for (int i = 0; i < filled; i++) if (names[i].equals(product)) return quantities[i];\n"
              + "    return 0;\n"
              + "  }\n"
              + "  public void updateQuantity(String product, int quantity) {\n"
              + "    for (int i = 0; i < filled; i++) if (names[i].equals(product)) { quantities[i] = quantity; return; }\n"
              + "    if (filled == names.length) throw new IllegalStateException(\"repository capacity full: \" + product + \" could not be added\");\n"
              + "    names[filled] = product; quantities[filled] = quantity; filled++;\n"
              + "  }\n"
              + "}\n");

        Path cInterface = root.resolve("output/interface");
        Path cCaller = root.resolve("output/caller");
        Path cMemory = root.resolve("output/memory");
        Path cArray = root.resolve("output/array");

        compile(cInterface, List.of(), List.of(sInterface));
        compile(cCaller, List.of(cInterface), List.of(sContainer, sService, sMain));
        compile(cMemory, List.of(cInterface), List.of(sMemory));
        compile(cArray, List.of(cInterface), List.of(sArray));

        System.out.println("-- same caller (Container+Service+Main), with the MemoryRepository implementation --");
        System.out.println(run(List.of(cCaller, cInterface, cMemory), "MemoryRepository"));

        System.out.println("-- same caller, without ever recompiling, with the ArrayRepository implementation --");
        System.out.println(run(List.of(cCaller, cInterface, cArray), "ArrayRepository"));
        System.out.println("defect type: error (failed with the exception name, ArrayRepository rejected the call that exceeded its capacity)");
    }
}
-- same caller (Container+Service+Main), with the MemoryRepository implementation --
screw: 0 -> 5
screw: 5 -> 3
nail: 0 -> 3
-- same caller, without ever recompiling, with the ArrayRepository implementation --
exception: IllegalStateException: repository capacity full: nail could not be added
defect type: error (failed with the exception name, ArrayRepository rejected the call that exceeded its capacity)

Nowhere in Service‘s source does the name MemoryRepository or ArrayRepository appear — there is only a Repository repository field, and that field’s type is the interface. Container.create works the same way: the only information it receives is a string, and it casts the class it finds with Class.forName to Repository. These three classes are compiled in the first compile call, in an environment where no implementation is present on the classpath, and their compiled form is reused across two separate run calls. The only difference is the third component of the classpath list given to run.

What confirms that this compilation is genuinely “without recompiling” is that the compile(cCaller, ...) call is made only once. The cCaller directory is produced once and given to both run calls exactly as is; before the second run call, there is no line anywhere that goes back to the Container, Service, or Main source and calls compile again. In a real setup, this means an application’s deployment package (the part holding the caller classes) can be produced once, and which implementation gets attached is decided by the runtime classpath alone — the same .class files can be bound to a different repository with no change in the source code, running no recompilation step at all.

The Interface’s Promise and the Implementation’s Behavior Are Separate

  • WD18. MemoryRepository works on a HashMap and places no limit on how many distinct product names arrive. ArrayRepository uses two fixed-length arrays and can hold only one distinct product name.
  • WD19. The Repository interface states this capacity difference nowhere; neither method’s signature carries any promise about a limit.

In the first run, screw is updated twice (0 -> 5, then 5 -> 3), and then a new record is opened for a different product (nail) (0 -> 3). With MemoryRepository, all three calls complete without a hitch. With ArrayRepository, the same three calls, in the same order, are made through the same compiled Service class — but the third one falls with an exception: ArrayRepository can hold only one distinct product name, and its capacity fills once nail arrives as a second product name.

This is the lesson’s bounding measurement: the interface’s promise and the implementation’s behavior are separate, and the container does not close this gap, it only hides it. The Repository interface nowhere promises that updateQuantity will always succeed, but in practice, while working with MemoryRepository, this promise looks true without ever being tested — since the limit is never exceeded, the problem never surfaces. When the same code is bound to ArrayRepository, a capacity limit the interface never promised suddenly becomes visible. The container hides from the caller which implementation Service is working with, but it does not eliminate the behavioral difference between the implementations — it only defers where that difference will surface, behind the interface, to runtime.

There is also a reverse side to this distinction: both implementations give byte-for-byte the same result for the two calls on the screw product. This is what the interface really delivers — a read-write cycle done with a single key behaves the same regardless of implementation. The problem arises only because an assumption the interface never states (an unlimited number of distinct keys) holds under one implementation and fails under the other. An interface promises the methods whose signatures it carries; everything not present in the signature — capacity, concurrency, latency — is the implementation’s own decision, and the interface never carries it at all.

This also clarifies the container’s real contribution in the data access layer. The container decides which implementation will fill the Service class, but it nowhere guarantees that this decision selected the correct implementation — Container.create only learns, at runtime, that the string name it was given really points to a class implementing Repository, and only as long as the cast at the line (Repository) c.getDeclaredConstructor().newInstance() does not fail. Likewise, an implementation violating an assumption the interface does not carry only surfaces once that assumption is genuinely tested — a caller using a single product name with ArrayRepository would never see the capacity limit. This lesson’s third call carrying a different product name is a deliberate choice: the limit only becomes visible once two distinct keys reach the same repository, and this is exactly why testing an interface has to ask not only “is the method being called” but also “where are the implementation’s real limits.”

Summary

  • The Container, Service, and Main classes were compiled once against only the Repository interface and run against two separate implementations (MemoryRepository, ArrayRepository) without ever being recompiled.
  • No implementation class’s name appears in the caller’s class file; Container.create finds the implementation only by a string name, through reflection.
  • The same three calls completed without a hitch under MemoryRepository; under ArrayRepository, the third call fell with an IllegalStateException — the same compiled code, the same calls in the same order, two separate outcomes.
  • The Repository interface carries no guarantee, such as capacity, that is absent from its signature; differences like this surface only when the implementation changes.
  • The container hides the implementation choice from the caller but does not eliminate the behavioral difference between implementations; it only defers that difference to runtime.

Next Step

This lesson measured which implementation stands behind an interface, but it never asked how the implementation’s own fields match between an object and a repository. The next lesson takes this up: it measures that the binding between an object’s fields and a table’s columns comes from an annotation in the source, and that a field that is never accessed is never loaded at all.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close