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.
@Endpointis only ever placed on a method and carries a single field:path. The party that reads this annotation is thescanmethod 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
requestmethod 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.
BackupControllerdeclares the same path (/product/list) asProductController. 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 thescancall. - 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
@Endpointannotation; there is no call in the source that builds the routing table, the table is built by the annotations thescanmethod 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.