How many times have you sat in a three-hour system design meeting, staring at a Miro board covered in complex microservices, event buses, and caching layers, only to leave with more questions than answers? We’ve all been there. We call it "architecture design," but if we are honest, it’s often just analysis paralysis in disguise. We try to aim perfectly before we ever throw a single line of code into production.
This week, a fascinating philosophical concept has been making waves in the developer community, sparked by a viral essay titled "My Throw Decides My Aim." Originally a concept from zen archery and phenomenology, it asserts that we cannot truly know our target—or how to hit it—until we actually make the throw. The act of throwing reveals the physics of the environment, the weight of the projectile, and the reality of the target.
For us as software engineers, DevOps practitioners, and system architects, this is a profound truth. In modern software development, your throw decides your aim. Trying to architect the perfect, infinitely scalable system on day one is a fool's errand. Instead, the most successful engineering teams build by putting software in motion first, using real-world telemetry to shape and aim their architecture. Today, we’re going to explore why "action-first engineering" beats big upfront design, and look at concrete ways to build systems that are designed to be steered mid-flight.
The Fallacy of the Perfect Architectural Blueprint
Traditionally, software engineering borrowed its metaphors from civil engineering. We talked about "blueprints," "foundations," and "building bridges." But software is not concrete; it is more like a living organism or a fluid dynamic system. When we attempt to design a highly complex cloud infrastructure or application architecture entirely on paper, we make a critical assumption: that we can predict the future.
We predict traffic patterns, user behavior, database access methodologies, and network bottlenecks. Then, we write thousands of lines of infrastructure-as-code (IaC) and application boilerplate to support this hypothetical world.
What happens instead?
- The database bottleneck isn't where we expected; instead of read-heavy queries, we get hit with a write-heavy event storm.
- The microservices boundary we drew so cleanly on our diagram introduces terrible network latency, forcing us to constantly make chatty RPC calls.
- The expensive managed queue system we provisioned sits idle because a simple Postgres-based queue would have sufficed.
By making the "throw" (deploying a minimal viable architecture) first, we get immediate, empirical data. The system itself tells us where it wants to break. That real-world feedback loop is what actually decides our "aim" (our ultimate architectural destination).
Building for Steerability: Designing for the "Throw"
If we accept that our initial architecture will be wrong, our primary goal as developers shouldn't be to build the correct system on day one. Our goal must be to build a system that is highly steerable. A steerable system is easy to refactor, easy to instrument, and cheap to throw away.
Let’s look at a practical example. Imagine we are building a notification dispatch engine that needs to handle email, SMS, and push notifications. Instead of immediately setting up an enterprise-grade message broker like RabbitMQ or Apache Kafka with multiple partitions and dead-letter queues, we start with a simple, monolithic, in-memory queue or a basic Postgres table.
Step 1: The Initial Throw (Monolithic & Monitored)
Our initial implementation uses a simple Go-based worker pool that reads from a PostgreSQL-backed queue table. It’s simple, easy to deploy, and cheap to run. But—and this is the crucial part—we instrument it heavily from day one.
package main
import (
"context"
"database/sql"
"log"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// Define metrics to observe the system's actual behavior
var (
jobsProcessed = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "notification_jobs_processed_total",
Help: "The total number of processed notifications",
},
[]string{"status", "type"},
)
jobLatency = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "notification_job_duration_seconds",
Help: "Latency of notification delivery in seconds.",
Buckets: prometheus.DefBuckets,
},
[]string{"type"},
)
)
type NotificationJob struct {
ID int64
Type string
Payload string
}
func ProcessJobs(ctx context.Context, db *sql.DB) {
for {
select {
case <-ctx.Done():
return
default:
job, err := fetchNextJob(db)
if err != nil {
time.Sleep(1 * time.Second)
continue
}
start := time.Now()
err = deliverNotification(job)
duration := time.Since(start).Seconds()
if err != nil {
jobsProcessed.WithLabelValues("failed", job.Type).Inc()
failJob(db, job.ID)
} else {
jobsProcessed.WithLabelValues("success", job.Type).Inc()
jobLatency.WithLabelValues(job.Type).Observe(duration)
completeJob(db, job.ID)
}
}
}
}
This code is not "perfectly scalable." It relies on a single relational database for queuing, which won't handle 100,000 requests per second. But it is an excellent "throw." It gets the feature into production instantly, and it exposes Prometheus metrics that will tell us exactly when, why, and how the system begins to struggle.
Using Telemetry to Fine-Tune Our Aim
Once this initial code is in production, we stop guessing. Our telemetry data becomes our guide. We might notice through our Prometheus histograms that email delivery latency is averaging 2.5 seconds because of external SMTP handshakes, while SMS delivery takes only 150 milliseconds.
Because the emails are blocking our single worker loop, SMS notifications are getting backed up. Our "aim" is now clear: we don't need a massive Kafka cluster. We just need to decouple our worker pools by notification type so that slow SMTP connections don't block fast SMS dispatches.
Refactoring to Decoupled Workers
Now, we adjust our throw. We can spin up distinct worker pools or separate microservices targeting specific queues based on our observed data, without changing our core business logic:
// Based on real telemetry, we split our worker pools
func StartEmailWorkers(ctx context.Context, db *sql.DB, numWorkers int) {
for i := 0; i < numWorkers; i++ {
go workerLoop(ctx, db, "email")
}
}
func StartSMSWorkers(ctx context.Context, db *sql.DB, numWorkers int) {
for i := 0; i < numWorkers; i++ {
go workerLoop(ctx, db, "sms")
}
}
By moving in increments—throwing, observing where the ball lands, and adjusting our aim—we avoid over-engineering. We didn't waste time building complex distributed locking mechanisms that we didn't end up needing.
The DevOps Perspective: Infrastructure as an Evolutionary System
This philosophy doesn't just apply to application code; it is incredibly powerful for cloud infrastructure and DevOps. When spinning up infrastructure on AWS, Azure, or GCP, developers often over-provision resources "just in case." We build multi-region Active-Active deployments with cross-region database replication before we even have ten active users.
Instead, apply the "Throw and Aim" loop to your infrastructure:
- Start with a monolith on a single VPS or a container runner (like AWS ECS Fargate or GCP Cloud Run). This keeps your operations simple, deployment pipelines fast, and cloud costs extremely low.
- Set up basic autoscaling metrics based on CPU and Memory. Do not guess your scaling thresholds. Let your application run under load, and watch where the bottlenecks occur.
- Refactor to managed services only when operational overhead becomes the bottleneck. Don't start with a managed Redis cache. Add it when your database read latencies start spiking and your telemetry proves that caching is the optimal solution.
The Guardrails of Action-First Engineering
Throwing before you aim can sound like an excuse for writing messy, unmaintainable code. Let's be clear: it is not an excuse for sloppy engineering. In fact, to safely throw and adjust your aim, you need highly robust guardrails. If you throw a spear in the dark without a target or boundaries, you are going to hurt someone.
To practice action-first engineering safely, you must invest heavily in three pillars:
- High Test Coverage (especially integration tests): You must be able to aggressively refactor your implementation details (e.g., swapping a Postgres queue for Redis) with absolute confidence that you haven’t broken your core business rules.
- Stellar Observability: You need deep visibility into your system. If you don't have structured logging, distributed tracing (like OpenTelemetry), and metrics collection, you are flying blind. You won't know where your throw landed.
- Rapid Deployment Pipelines (CI/CD): If it takes your team two weeks to deploy a change to production, you cannot afford to "steer" your architecture mid-flight. Your feedback loop must be measured in minutes, not days.
Conclusion: Stop Designing, Start Throwing
The next time you find yourself stuck in a massive architectural debate about how your system *might* scale in three years, step back. Realize that your theoretical aim is likely off because you haven't thrown the ball yet.
Build the simplest, most observable version of the feature that could possibly work. Put it in production. Let your users interact with it, let your database sweat under its load, and watch your metrics dashboard. Let your throw decide your aim. You will find that you build simpler software, delete less code, and deliver actual value to your users infinitely faster.
What do you think?
Are you currently over-architecting a system for scale that doesn't exist yet? How can you simplify your current stack to make it more "steerable"? Let's chat in the comments below, or hit me up on Twitter/X at @CodingWithAlex!