I've always been drawn to the idea of simplifying complex concepts into core principles. For me, it's like holding onto a "root node" from which I can navigate the many branches of a problem. Recently, I've found a way to generalize one of the trickiest aspects of software design: dependencies—specifically, runtime dependencies. This article shares some insights and strategies for dealing with them effectively. If you've encountered exceptions to these ideas, I'd love to hear about them!
What Are Runtime Dependencies?
At their core, runtime dependencies boil down to two categories:
Execution Order Dependencies: When one task must finish before another can start.
Data or Resource Dependencies: When one task requires data or resources generated by another or when tasks share the same resource(s).
These dependencies create challenges that need careful handling to avoid bottlenecks, resource contention, or cascading failures. Let’s dive into some strategies to manage them.
Decoupling with a Third Component
Whenever you’re dealing with runtime dependencies, the most reliable solution is to decouple them using a third component. This component acts as a mediator and can take various forms, such as a messaging queue, a buffer, or even a simple asynchronous function call.
The decoupling can happen in several ways:
The third component might live within one of the dependent components.
It might be an entirely separate entity.
Think of any runtime dependency you’ve resolved—chances are, the solution involved some flavor of this approach.
Three Key Strategies to Handle Dependencies
Runtime dependencies often manifest as "X needs to happen before Y." Whether the dependency is about shared data or sequential execution, you generally have three options:
Make Y Wait for X: This is the simplest approach: Y patiently waits until X is done. While it works fine for systems with low concurrency needs, it’s not scalable. Waiting introduces bottlenecks, slowing down the overall system.
Separate Independent Parts of Y: If only some parts of Y depend on X, isolate the dependent elements. The independent parts of Y can proceed while the dependent ones wait. This minimizes delays and keeps your system moving as efficiently as possible.
Precompute Multiple Instances of X: For systems that need to scale, this strategy is a game-changer. Execute X in advance, store the results, and let Y access them when needed. This approach:
Decouples timing between X and Y.
Reduces single points of failure by spreading the workload.
Enables reusability: if multiple instances of Y rely on X, precomputed results avoid redundant processing.
Boosts fault tolerance: if Y fails, X’s results are still intact.
Real-Life Example: A Buffer-Based System
Here’s how I applied these principles to a real-world problem:
I was working on a service for generating and scheduling automated tests for some declarative Infrastructure-as-Code (IaC) services. These tests had both order and data dependencies. For instance, you can’t unblock something that hasn’t been blocked, and you can’t block something that hasn’t been set up yet.
Initial Approach: Chained Scheduling
We initially considered a straightforward approach: chain the test executions together. However, this quickly became problematic:
Tight Coupling: Delays in one test could cascade down the chain, impacting all dependent tests.
Cascading Failures: If one service failed, it could take other tests down with it.
Resource Contention: Multiple operations competing for shared resources caused conflicts.
Final Solution: A Buffer-Based System
The solution was to decouple dependencies using a buffer. Each application had its own "buffer group" with individual queues for each test. This setup allowed tests to execute independently while filling the buffer with data for subsequent operations. If one test failed, its buffer data remained available for retries or debugging.
We opted to persist buffer data in a database rather than a queue. While some queue systems support persistence, databases provided greater flexibility for accessing and managing data in the long term. The principle, however, remains the same: decouple dependencies with a third component.
Wrapping Up
While this article focused on technical solutions, the underlying principle of decoupling dependencies applies beyond software design. Do your part well asynchronously and bring in a mediator where things get out of hands.