1. Code that still works 😅
There is a kind of architectural pain like this: the code still works, but it is already unpleasant to change 🙊. An order is created, a message is printed to the console, the service runs — everything seems fine. And then a small change comes along — switch the notification channel, remove randomness from id generation, replace audit in a test — and suddenly it turns out that the code is sticky.
This is an important adult thought for day one. Architectural problems rarely start with a catastrophe. Much more often they start with a feeling of stickiness: one small change pulls edits in unexpected places, the use-case service knows too much, and the “quick check” requires launching half the application. If you do not name it, Spring can easily look like a complicated solution to a non-obvious problem. But the problem is actually very obvious — it is just quiet at first.
In the context of ContextFlow, we are interested in three symptoms: tight coupling, hidden dependencies, and weak testability. They appear even in plain Java, long before the first ApplicationContext. And that is good news: it means we can learn to see them today, without waiting for the framework.
2. Tight coupling: the class knows too much 🔒
Coupling itself is not evil. Any useful service depends on something: a repository, a notification sender, an id generator, an audit component. The problem starts when a class depends not on a role, but on a concrete implementation. When a use-case service says not “I need someone who can send notifications,” but “I will create ConsoleNotificationSender myself and live with exactly that.”
Such code often looks short and even neat:
public class OrderPlacementService {
public void placeOrder(CreateOrderCommand command) {
// Input parameter is a use-case command: it carries data from the outside world.
// Important: the business method accepts command, but below it chooses infrastructure on its own.
// The service chooses a concrete notification implementation itself.
// This is tight coupling: the dependency is "nailed down" to a specific class.
ConsoleNotificationSender sender = new ConsoleNotificationSender();
// Here business logic calls an infrastructure detail directly (the console).
// If the notification channel changes, this service will need to be edited.
sender.send("Order created for " + command.customerName());
}
}
At first glance, there is no drama 🙂. The method is small, the behavior is clear, and there is a result. But architecturally, this is already tight coupling. The business service knows that notifications are sent specifically through the console. If tomorrow you need EmailNotificationSender, you will not be able to simply “plug in another implementation.” You will need to change the use-case service itself. And that means the infrastructure decision has already leaked into business code.
The contrast becomes especially clear if we look at a more honest form of dependency:
public class OrderPlacementService {
// The dependency is expressed as a "role" (interface/abstraction), not as a concrete class.
// This makes the replacement point explicit.
private final NotificationSender sender;
public OrderPlacementService(NotificationSender sender) {
// Dependency injection through the constructor: the service does not create anything itself.
// Now external code (wiring/container) is responsible for choosing the implementation.
this.sender = sender;
}
public void placeOrder(CreateOrderCommand command) {
// Here the service uses a collaborator through its contract.
// Internal code does not know whether it is console, email, or something else.
sender.send("Order created for " + command.customerName());
}
}
In the second version, the class still depends on an external object, and that is fine 🔥. But now it depends on the role NotificationSender, not on a specific detail. The decision about which sender to use is moved outside. The use-case service remains a service, not the place where infrastructure is selected. And that is directly related to why you later need a container.
3. Hidden dependency: the necessary thing is hidden inside the method
A hidden dependency is unpleasant because the code looks simpler from the outside than it really is. A class may have no fields, a method may have a short signature, and then suddenly audit, an id generator, a file-writing class, or something else is created inside it, without which the behavior is impossible at all 🤫. In other words, the real contract of the class is not reflected in its shape.
The simplest example looks like this:
public class OrderPlacementService {
public String placeOrder(CreateOrderCommand command) {
// The method signature says only "give me a command — I will return a String".
// But the real dependencies (randomness and audit) are hidden inside.
// Hidden dependency: id generation is hard-coded directly into the method.
// This introduces randomness, which later gets in the way of tests/demo mode.
String orderId = UUID.randomUUID().toString();
// Hidden dependency: audit is created inside the business method.
// Important: the audit itself is not visible from the outside (neither in fields nor in the constructor).
AuditWriter audit = new ConsoleAuditWriter();
// The side effect (audit) happens right in the middle of the business scenario.
// If we want to change the audit channel, we will need to change this code.
audit.write("Created order: " + orderId);
// Return the id: this looks like the "main result", although side effects already happened inside.
return orderId;
}
}
Why is this a problem? Because the person reading the class from the outside does not see half of reality. From the method signature, it is not obvious that there is audit here at all. From the class fields, it is not visible that the scenario depends on the id generation strategy. So the behavior starts living in hidden corners. And hidden corners are a bad place both for maintenance and for local diagnosis 🕳️.
There is one more unpleasant aspect. A hidden dependency makes a requirement change more expensive than it should be. While UUID.randomUUID() sits inside the business method, the request “in demo mode, make ids predictable” turns into a change to the use-case service. While ConsoleAuditWriter is created inside the method, changing the audit channel also becomes a change to the use-case service. In other words, a technical detail starts to look like business logic 🧨.
4. The cost of change and testability
How do you know that an architectural problem is no longer abstract, but very practical? Look at the cost of a small change. Suppose you need to check that OrderPlacementService really sends the right notification. If the dependency is hidden and the concrete implementation is created inside the method, then the simplest way to verify it is to look at the console with your eyes 😬. Formally, that is a “check.” In engineering terms, it is almost nothing.
To feel the difference, it is enough to make a very simple fake:
public class FakeNotificationSender implements NotificationSender {
// Store the last message so that a test/check can read it.
// This is "observable" state — our simple test hook.
String lastMessage;
@Override
public void send(String message) {
// The fake does not send anything anywhere, it only remembers the input data.
// Important: there is no IO, no network, and no console here — so the check is cheap and fast.
lastMessage = message;
}
}
Then the service behavior can be checked without the console, files, or randomness:
public class ManualCheck {
public static void main(String[] args) {
// Setup: create a fake dependency instead of a real sender.
FakeNotificationSender fake = new FakeNotificationSender();
// Manual wiring: pass the dependency into the service constructor.
// This becomes possible precisely when the dependency is not created inside the method.
OrderPlacementService service = new OrderPlacementService(fake);
// Execute the use case with specific input data.
service.placeOrder(new CreateOrderCommand("Alice"));
// Check: compare the expected message with what the fake "saw".
// This is isolated behavior verification without external effects.
if (!"Order created for Alice".equals(fake.lastMessage)) {
// If something is wrong, fail with a clear error.
throw new IllegalStateException("Unexpected message: " + fake.lastMessage);
}
// For visibility, print success (this is not part of the business check, just a signal).
System.out.println("OK");
}
}
There is no JUnit, no mock framework, and no large test architecture here 👌. And that is useful. It immediately becomes clear that testability starts not with libraries, but with the structure of dependencies. If a class receives a dependency from outside, its behavior can be checked in isolation. If it creates a concrete dependency itself inside the method, isolation becomes dramatically more expensive 💸.
The same thing happens with the cost of change. A class that chooses ConsoleNotificationSender on its own is hard to switch to email. A class that calls UUID.randomUUID() on its own is hard to make deterministic for a demo or a test. A class that creates ConsoleAuditWriter inside a business scenario is hard to adapt to a new audit mechanism. And all of these are signs of the same problem: decisions about assembly and implementation choice live where they should not.
5. Detecting manual wiring
The first day should leave not only a general idea, but also a useful tool. So here is a simple diagnostic formula. It can be applied not only to ContextFlow, but to any ordinary Java code you read by hand 📎. The question here is not whether Spring exists, but who is responsible for assembly and how transparent that is.
| Question | If the answer is “yes” | What it usually means |
|---|---|---|
| Does the business class create its own dependency with new? | the use-case service chooses the implementation itself | tight coupling |
| Is the dependency visible only inside the method, not in the class shape? | the class contract is implicit | hidden dependency |
| Does replacing notification, audit, or id generation require editing business code? | an infrastructure detail has leaked into the use-case layer | high cost of change |
| To verify behavior, do you need to look at the console, a file, or live with randomness? | behavior is hard to isolate | weak testability |
| Is there no single place where the full application wiring is visible? | assembly decisions are scattered | bootstrap starts to spread out |
This table is useful precisely because it is practical ⚒️. It does not require deep terminology. You simply take a service and ask five questions. If the answers are worrying, then the problem is no longer about “code style,” but about responsibility for assembling the application. And that is exactly the area where the Spring container will appear later.
There is another practical benefit. The table helps you avoid the false extreme of “any new is evil.” No, not any. What becomes alarming is not the operator itself, but the place where it lives and the role it performs. new Order(...) inside a business scenario may be completely normal. new ConsoleNotificationSender() inside a business scenario is already much more suspicious.
6. The problem is not new
This is where the first real turn of the whole level happens. The problem is not that new appears in the code. And it is not that a class has dependencies. Normal code will always have dependencies. The problem begins where business classes themselves take on the management of creating, choosing, and wiring the entire object graph 🔧.
Important: Spring appears not because the developer got tired of writing new. Spring appears where assembling application objects has already become a separate engineering task, and that task needs to be moved out of business code.
It is useful to keep this in mind going forward. Otherwise it is easy to fall into a caricature: “Spring is needed to write less by hand.” No. It is needed so that the application has a managed place where the rules for creating and wiring objects live. Less new is only a side effect. The main gain is not fewer characters, but moving responsibility to the right layer.
Once this thought clicks, the whole picture starts to come together. Tight coupling, hidden dependencies, testability, a spreading bootstrap — these are not five different problems. They are different manifestations of the same architectural problem. And so the answer to them is also systemic, not local 🔩.
7. What naturally follows from this
If a business class should not choose its own notification sender, audit, and id generator, then those decisions must live somewhere else 🤔. And not chaotically, but in a managed place where the full dependency graph, the order of its assembly, and the rules for choosing implementations are visible. This is where container logic comes very close, although we are not touching the framework itself yet.
For now, it is enough to keep one simple conclusion in mind. A good project design makes dependencies explicit and moves assembly out of business logic. A bad design hides dependencies inside methods and forces use-case services to play the role of tiny factories 😐. It is from this conclusion that the next question grows: if assembly needs to be moved outside, who should take control of it, and how?
Yes, this is Spring. And it interests us not as a set of convenient annotations, but as an engineering model. The container does not “add magic” to the application. It takes over the part of responsibility that has already started to get in the way of the plain Java version. And as soon as that is clear, the conversation about ApplicationContext stops being a conversation about abstraction. It becomes a conversation about very concrete work that someone will have to do anyway 🔧.
8. Typical mistakes 🧗
Mistake #1: start fighting new as absolute evil.
That way the essence is easily lost. The question is not the operator itself, but where decisions about creating and choosing dependencies are made. new Order(...) and new ConsoleNotificationSender() inside a use-case service are not the same story.
Mistake #2: declare any dependency a sign of bad architecture.
Normal code depends on other objects. The problem is dependency on a concrete implementation or a dependency hidden so that the class cannot be honestly read from the outside.
Mistake #3: try to cure everything with total abstraction.
You do not need an interface for every class just because the word coupling sounded alarming. Abstraction is useful where there is a real role, a replacement point, or an infrastructure boundary. Otherwise you will just end up with a lot of extra types.
Mistake #4: confuse “the code runs” with “the code is easy to evolve.”
Working code is not an indulgence 👍. Architectural pain often appears before failures: in difficult changes, awkward checks, and in use-case services starting to know too much.
GO TO FULL VERSION