Skip to content
academia.sh

Lesson 08 / 19

Controllers and Routing

All four of the four endpoints are declared in the source with only an annotation, no call at all. When an unmatched path is requested, the object container does not raise an error, it returns a fixed default response; when two components declaring the same path are scanned, the winner depends on the scan order.

Published

In this lesson

  • Shows that all four endpoints are declared in the source with only an annotation, no call at all
  • Measures that the container returns a fixed default response without raising an error when an unmatched path is requested — that is, the defect stays silent
  • Shows, by running it, that the winner depends on the scan order when two components declare the same path
Contents

The previous lesson measured which links a request passes through while going through the container, but it never settled which job the request is met with at the end of the chain. This lesson closes that gap: it measures which annotation, not which call, declares an endpoint in the source, and what the container answers to a request that does not match. Resource design — which resource and which action a path represents — was established as a concept in the Web API Design course and is not repeated here; this lesson’s question is separate from that — how a path exists in the source at all.

The controller is modeled here too: no real HTTP method is parsed, no real request body is decoded. A “path” is just a string (like "/product/list"), and an endpoint is a mapping that binds this string to a Java method. The links in the previous lesson changed the request, they did not route it; this lesson’s controllers, in contrast, route the request to the correct method without changing it at all — this is what the course’s terminology decision means by routing.

A controller here is an ordinary Java class; the only thing that makes it a controller is that some of its methods carry the @Endpoint annotation. The class itself implements no interface, extends no superclass — the container recognizing it as a “controller” comes not from the class’s type, but from it being handed to the scan call by hand. This is the web layer’s second modeling decision in this course: in real frameworks, class scanning usually walks the entire classpath, while here the objects to be treated as controllers are given to scan explicitly, as an array. The modeling difference does not change the outcome — in both cases, there is no line in the source that calls the controller class, only an annotation standing on top of it.

All Four of the Four Paths Arise from Annotations

  • WD6. @Endpoint is only ever placed on a method and carries a single field: path. The party that reads this annotation is the scan method below.
  • WD7. The routing table is kept as a Map<String, Method>; the key is the path string, the value is the method to be called through reflection. There is no real request-body parsing or content-type negotiation here.

The two controller classes together define four methods, all four carrying @Endpoint. There is no call anywhere of the form container.add("/product/list", ...) — unlike the previous lesson’s links added through a call, here the entire routing table is built by the scan call reading the annotations on the classes. This is one extreme, set apart from the previous lesson’s mixed source distribution (two default, two annotation, two call): all four of the four endpoints arise from an annotation, there is neither a default endpoint nor a manually added call.

// Container.java — endpoints arise from annotations, collisions depend on registration order
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;

public class Container {

    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
    @interface Endpoint { String path(); }

    final Map<String, Method> routes = new LinkedHashMap<>();
    final Map<String, Object> owners = new LinkedHashMap<>();
    final List<String> registrationOrder = new ArrayList<>();

    void scan(Object... controllers) {
        for (Object c : controllers) {
            for (Method m : c.getClass().getDeclaredMethods()) {
                Endpoint e = m.getAnnotation(Endpoint.class);
                if (e == null) continue;
                routes.put(e.path(), m);
                owners.put(e.path(), c);
                registrationOrder.add(e.path() + " <- " + c.getClass().getSimpleName());
            }
        }
    }

    String request(String path) {
        Method m = routes.get(path);
        if (m == null) return "default-response: page not ready";
        try {
            return (String) m.invoke(owners.get(path));
        } catch (ReflectiveOperationException e) {
            throw new RuntimeException(e);
        }
    }

    public static void main(String[] args) throws Exception {
        ProductController product = new ProductController();
        StockController stock = new StockController();

        Container container = new Container();
        container.scan(product, stock);

        String[] fourPaths = {"/product/list", "/product/add", "/stock/status", "/stock/alert"};
        System.out.println("-- all four paths arose from annotations --");
        int fromAnnotation = 0;
        for (String path : fourPaths) {
            String response = container.request(path);
            System.out.printf("%-16s%s%n", path, response);
            if (container.routes.containsKey(path)) fromAnnotation++;
        }
        System.out.println("paths that arose from an annotation: " + fromAnnotation + "/4");

        System.out.println("-- unmatched path --");
        System.out.println("/product/delete " + container.request("/product/delete"));
        System.out.println("defect type: silent (no exception, a fixed default response came back)");

        System.out.println("-- colliding endpoint: two components declare the same path --");
        BackupController backup = new BackupController();

        Container containerA = new Container();
        containerA.scan(product, backup);
        System.out.println("scan order product-backup, /product/list response: " + containerA.request("/product/list"));

        Container containerB = new Container();
        containerB.scan(backup, product);
        System.out.println("scan order backup-product, /product/list response: " + containerB.request("/product/list"));

        System.out.println("do the two scan orders give the same response: "
                + containerA.request("/product/list").equals(containerB.request("/product/list")));

        System.out.println("-- manually called controller: independent of container registration --");
        System.out.println("product.list() called directly: " + product.list());
        System.out.println("/product/list through containerA (last registered was backup): " + containerA.request("/product/list"));
        System.out.println("does the manual call match the container response: " + product.list().equals(containerA.request("/product/list")));
    }
}

class ProductController {
    @Container.Endpoint(path = "/product/list")
    public String list() { return "3 products"; }

    @Container.Endpoint(path = "/product/add")
    public String add() { return "product added"; }
}

class StockController {
    @Container.Endpoint(path = "/stock/status")
    public String status() { return "stock: 42"; }

    @Container.Endpoint(path = "/stock/alert")
    public String alert() { return "no alert"; }
}

class BackupController {
    @Container.Endpoint(path = "/product/list")
    public String list() { return "backup list"; }
}
-- all four paths arose from annotations --
/product/list   3 products
/product/add    product added
/stock/status   stock: 42
/stock/alert    no alert
paths that arose from an annotation: 4/4
-- unmatched path --
/product/delete default-response: page not ready
defect type: silent (no exception, a fixed default response came back)
-- colliding endpoint: two components declare the same path --
scan order product-backup, /product/list response: backup list
scan order backup-product, /product/list response: 3 products
do the two scan orders give the same response: false
-- manually called controller: independent of container registration --
product.list() called directly: 3 products
/product/list through containerA (last registered was backup): backup list
does the manual call match the container response: false

All four of the four endpoints are confirmed in the paths that arose from an annotation: 4/4 line. Someone reading the source looks at the ProductController and StockController classes and sees four @Endpoint annotations, but cannot infer from the source that these four annotations really turn into a routing table — the scan method that does the turning sits somewhere the calling code never sees. This reproduces the same reading problem as the previous lesson: an annotation leaves a mark in the source, but whether that mark really turns into a behavior can only be understood by reading the container’s own code.

At this point, the two controller classes have no function on their own — ProductController and StockController compile, can be instantiated, and their methods can be called, all without ever being introduced to the container. What makes a class a “controller” is not its own code, but the container recognizing it. This repeats the same pattern seen with the @Link annotation in the previous lesson: an annotation is a request as long as it sits on the class, and without a reader to fulfill the request, the class is no different from an ordinary Java class. The difference is this: the reader that fulfills the request in this lesson does not set the class up as a component, it only binds one of its methods to a path string. The same annotation mechanism can produce a completely different outcome depending on the topic — the container decides what to do by looking at the information the annotation carries and at its own rule.

An Unmatched Path: A Silent Default

  • WD8. The request method returns a fixed string when it finds no match, throwing no exception at all. Because this fixed string is of the same type (String) as a normal response, the caller gets a “response” without ever being forced to tell the two apart.

/product/delete is not defined in any controller, and the call container.request("/product/delete") returns "default-response: page not ready" instead of throwing an exception. This is an example of the silent one of the course’s four defect classes: the program does not say it is crashing, it does not print an error message, it just continues with an unexpected response. If a developer makes a typo like /product/deletee, nothing anywhere blows up — a response comes back, its form is correct, but its content is never the content a real endpoint would have produced. The only thing that catches the defect is deliberately comparing the response’s content; the response’s mere existence proves nothing.

This behavior is a design decision, not a shortcoming — the container produces the same fixed response for every unmatched path, and that is consistent. But consistency is not the same as correctness: a path always producing the same response when it goes unmatched does not mean that response is what the caller expected. In the Inversion of Control lesson, the container threw an IllegalStateException when a missing type was requested — there, the container was trying to construct a class it knew nothing about, and that was genuinely an unexecutable state. Here, the container is in an executable state when it fails to find a path, and it chooses to stay silent, because carrying the information “this path does not exist” as a response body instead of an exception is also a decision the container makes.

The alternative to this decision can be seen not in the container but in the method’s own signature: if request returned an Optional<String> instead of a String, the caller would be forced to check for the unmatched case — the type system would carry the unmatched path as a separate state. The signature chosen here does not do that; “found” and “not found” come back in the same type, in the same form. The bounding measurement sits exactly at this point: the defect staying silent is not a weakness of the container, it is the direct consequence of the return type not distinguishing the matched state. Whoever writes a routing rule has to design this distinction deliberately; otherwise every typo results in a response that looks error-free but is wrong.

Colliding Endpoints: The Winner Depends on the Scan Order

  • WD9. BackupController declares the same path (/product/list) as ProductController. The container overwrites the entry previously written under the same key with whichever one is written second; which entry counts as “second” depends on the order of arguments given to the scan call.
  • WD10. The container does not check for the collision — it prints no warning, throws no exception. Who the winner is can only be seen by comparing the two setups.

containerA scans product and then backup, in that order; containerB scans the same two controllers in reverse. The response given to a /product/list request differs between the two setups: in containerA the backup controller’s response wins (it was scanned last), and in containerB the original controller’s response wins (it was scanned last). There is no statement anywhere in the source text saying “this is the winner”; the only thing that decides the winner is the order of the array given to the scan call.

This stands in direct contrast to the link ordering in the previous lesson. There, the order of links found through an annotation was decided by the priority field and was completely independent of the scan order — two different orderings in the source produced the same result. Here the same scan pattern is used, but the routing table is built with a plain put on a Map, and there is no tie-breaking rule at all. The difference is not in the mechanism itself — both are a reflection scan reading annotations — but in whether the container has an explicit rule to resolve a collision. In the link ordering, the priority field supplied that rule; the routing table has no such field, so the collision falls not on any statement in the source, but on the scan order. An annotation being independent of ordering does not come for free; the container has to have a rule to resolve that ordering, and without that rule, what decides the winner is not the last line in the source, but the container’s own registration order.

A Manually Called Controller Is Independent of Container Registration

Calling product.list() directly always returns "3 products" — this is the behavior of ProductController‘s own method and depends on no container registration at all. A /product/list request through containerA, in contrast, returns "backup list", because BackupController was the one scanned last in that setup. The two responses differ, and this difference shows exactly the distinction from the start of the lesson: the product object’s own methods always run its own logic, while a response coming through the container depends on the container’s current registration state. The ProductController class itself never changed, its list() method still returns the same string — what changed is which method the container bound to the /product/list path. Calling a controller class by hand in a unit test therefore only confirms that class’s own logic; it confirms neither which path it is bound to nor whether that path collides with another component. The two questions are separate: is the method correct, and did the container bind it to the correct path — the first is answered by a manual call; the second can only be answered by running the container itself.

The scope of this distinction is a little broader than what was seen in the first lesson: there, a manual call only missed the default links (request-id, error-handling), because that behavior was part of the container’s own code. Here what gets missed is not a default, but the registration state the container currently holds — which component scanned which path last. A manual call never sees this state, because a manual call never asks the container at all; it just runs whatever is in the object’s own memory. Both kinds of dependency come from the same root — the container stepping in and making a decision on the work — but one is the container’s fixed behavior, the other is a behavior that changes with whatever state the container happens to be in at that moment.

Summary

  • All four endpoints arose from the @Endpoint annotation; there is no call in the source that builds the routing table, the table is built by the annotations the scan method reads.
  • When an unmatched path was requested, the container threw no exception, it returned a fixed default response; this is an example of the course’s silent defect class.
  • When two components declared the same path, the container did not check for the collision; the winner changed with the order of classes given to the scan call.
  • This differs from the link ordering in the previous lesson: there, the annotation order was fixed by the container’s own rule (priority); here, since there is no rule to resolve a path collision, the result stays dependent on the registration order.
  • Calling a controller class by hand tests only that class’s own logic; it does not test which path it is bound to or whether it wins a collision.

Next Step

This lesson measured which method a request reaches, but it never asked whether the data reaching that method is in the correct form. The next lesson takes this up: it measures whether a field’s validation rule is a call or an annotation in the source, and, when the rule is broken, which fields the common error response gathers and from where.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close