Dependency Direction Determines Code Layering
Does living in the same folder mean two classes are in the same layer?
Stop guessing from naming or folder location.
Take a three-layer chain, business logic, support logic, infrastructure, as the example.
This post works out whether the calls are actually same-layer.
Background
The external API this package integrates with requires a JWT token before every call.
To avoid re-exchanging the token on every call, the token gets cached in Redis.
The constructor of this integration package ended up passing three layers of dependencies down the chain.
In services/yoho/, the YohoRestClient constructor takes a YohoTokenProvider.
The YohoTokenProvider constructor, in turn, takes a RedisService.
All three classes live in the same folder, which set off alarm bells for me immediately:
Does this count as same-layer classes calling each other? Could there be a circular dependency?
But actually drawing the arrows out shows this isn’t three parallel services. It’s a one-way chain going downward:
YohoRestClient [Business Logic] Sends the request to the external system
β depends on
βΌ
YohoTokenProvider [Support Logic] Gets a valid OAuth token
β depends on
βΌ
RedisService [Infrastructure] Generic key-value cache
Notice that “the arrows only point downward, none of them point back.”
This matters a lot: every test later in this post comes back to check against it.
Folder and Naming
In most projects, the folder name services/ is just a loose classification label:
This is where business logic lives, not the controller layer, and not a pure data model.
It’s not declaring that “everything in this folder is horizontally equal and can’t depend on each other.”
Look closely at the existing naming, though, and it already draws a distinction, intentional or not:
| Class | Suffix | Actual Role |
|---|---|---|
RedisService |
Service | Wraps a generic piece of infrastructure (Redis), an external system |
MonkeyEmailService |
Service | Wraps a call to another external system (an email-sending API) |
YohoTokenProvider |
Provider | In the Yoho integration domain, plays the role of “supplying a token” |
YohoRestClient |
Client | In the Yoho integration domain, plays the role of “sending requests” |
What actually decides “is there a layering problem” is never the folder name or suffix. It’s:
- Does the dependency direction have a cycle?
- Does the dependency target cross into an unrelated business domain?
Neither has anything to do with what everyone else calls themselves.
Six Tests for Telling Layers Apart
Once you drop “going by feel,” these six questions can be applied to any pair of dependencies.
1. Dependency Direction Test
- Does A need to know B exists to get its own job done?
- Does B know A exists?
Only a one-way relationship, “A knows B, B has no idea A exists,” counts as layering.
Same-layer or parallel relationships mean neither side knows about the other, or a higher-level role coordinates them.
2. One-Sentence Responsibility Test
- Can you describe what this component does in one sentence, without mentioning the other component at all?
For example, YohoRestClient is “send the request to Yoho,” no need to mention Redis or JWT.
If both sides can be described independently, they’re not same-layer, they’re an upper/lower relationship.
If describing A forces you to drag in B’s details, that’s more like same-layer or overly tight coupling.
3. Substitution Test
- If you swap B for a different implementation, does A’s responsibility description change?
Swap RedisService for a different cache implementation.
YohoTokenProvider’s responsibility description doesn’t change at all.
That means B is a supporting component underneath A, not a same-layer partner.
4. Cross-Domain Reuse Test
- Does it make sense to hand this component to a completely unrelated business use case?
Handing RedisService to an unrelated email module to cache templates makes total sense.
But handing YohoTokenProvider to that same module makes no sense at all.
Whether something can be reused across domains is exactly the basis for judging how low-level it is:
The more it can be reused by unrelated business logic, the lower-level and more generic it is.
5. Change Ripple Test
- If B’s implementation changes, does A have to change too?
If Yoho swaps its JWT signing method, YohoRestClient doesn’t need to change.
Only YohoTokenProvider needs to be updated.
As long as the interface doesn’t change, the lower layer can change without forcing the upper layer to move.
That’s correct layering. If both sides keep changing together, the coupling is too tight.
6. Constructor Parameter Bloat Test
- If you remove the layer in between, does the upper object’s constructor get forced to take on a pile of parameters unrelated to its actual job?
If you don’t inject it and instead build the logic yourself, the constructor gets forced to accept a pile of unrelated parameters.
Without Layering
Instead of injecting YohoTokenProvider, let YohoRestClient build the authentication logic itself:
class YohoRestClient:
def __init__(
self,
base_url: str,
client_id: str,
redis_host: str,
redis_port: int = 6379,
) -> None:
...
# Build RedisService and YohoTokenProvider itself,
# and also has to remember when to refresh the token
With Layering
In services/yoho/rest_client.py, the YohoRestClient constructor takes a YohoTokenProvider.
The YohoTokenProvider constructor, in turn, takes a RedisService.
class YohoRestClient:
def __init__(
self,
base_url: str,
token_provider: YohoTokenProvider,
) -> None:
self.base_url = base_url.rstrip("/")
self.token_provider = token_provider
Factory Function
In the “without layering” version, three constructor parameters have nothing to do with “sending the request.”
That means two different responsibilities have been forced into the same constructor.
The “with layering” version stays clean.
That’s thanks to a separate factory function, which manually news up each object and wires them together:
factories/yoho/rest_client_factory.py
def build_yoho_rest_client(
env: str,
client_id: str,
base_url: str,
redis_host: str,
redis_port: int = 6379,
) -> YohoRestClient:
redis_service = RedisService(redis_host, redis_port)
token_provider = YohoTokenProvider(
client_id=client_id,
redis_service=redis_service,
)
return YohoRestClient(base_url=base_url, token_provider=token_provider)
The factory function knows the whole object graph, but each class only knows the layer right below it.
For example, YohoRestClient only knows about YohoTokenProvider.
It has no idea RedisService even exists.
A Follow-up Thought
Looking at this factory function, a question might come up:
- YohoTokenProvider receives a Redis object, but the factory function’s redis_host parameter is a string. Isn’t that inconsistent?
It’s not. The difference comes down to whether the thing has “behavior”.
The redis_host parameter passed in is just a hostname string, it doesn’t do anything on its own.
The factory sees it and just goes ahead and news up a RedisService with it. No need to pass it around as an object.
But the RedisService object is different!
Every time YohoTokenProvider.get_token() gets called, it has to decide, right then and there:
- Did the cache hit?
- Does the token need to be refreshed?
Only actually calling a method on this object can make that call. A string can’t answer these questions.
So the rule is simple:
Pass data for pure configuration. Inject an object for anything that gets called and does work.
Quick Reference
Next time you see a pair of dependencies and can’t tell whether they’re same-layer, run through these:
| # | Test | Question |
|---|---|---|
| 1 | Dependency Direction | Is the arrow one-way, with no cycle? |
| 2 | One-Sentence Responsibility | Can you describe what it does without mentioning the other? |
| 3 | Substitution | Swap the lower implementation, does the upper description change? |
| 4 | Cross-Domain Reuse | Does it make sense handed to an unrelated business use case? |
| 5 | Change Ripple | Change the lower implementation, does the upper need to change? |
| 6 | Constructor Parameter Bloat | Remove this layer, does the upper constructor get stuffed with unrelated params? |
