Skip to content

Engineering guide

These principles guide how software is cooking at Stratorys. They apply across architecture, code quality, operations, and product development. Their purpose is to make change cheap, reduce risk in production, and help teams ship value fast. Treat them as sensible defaults. If you need to break one, do it knowingly and write down why.

Design philosophy

Single source of truth

Every piece of knowledge must live in one place. When the same definition or rule appears in multiple places, those copies drift and you can no longer trust any of them. Databases schemas and migrations define the structure of data. The core domain layer defines the business rules. Configuration files and environment variables define runtime behavior.

In practice, this means we reuse the same domain types across the system instead of re-defining them in each layer. We avoid copy-pasting validation rules between the backend, the CLI, and the UI. When a rule changes (for example, what counts as a valid email) we change it once in the domain and everything else follows. You can tell this principle is working when questions like "which version of the user object is the real one?" stop coming up.

Separation of concerns

Each part of the system should have exactly one reason to change. Web handlers should speak HTTP and validate incoming data. The application layer should coordinate use cases. The domain should hold the rules and concepts of the business. The repository layer should know how to store and fetch data. When we mix these concerns - say, by placing SQL inside business logic or by doing business decisions inside an HTTP controller - simple changes turn into risky refactors.

Good separation makes testing easier. You can test domain rules without booting servers or touching databases. You can swap a database implementation without touching business code. You can add a new interface, such as a CLI, by reusing the same application and domain layers. If you ever feel forced to touch three or four different areas for a small change, the boundary is probably blurry and needs to be cleaned up. This layering is sometimes called hexagonal architecture or ports and adapters.

KISS: keep it simple

Simple designs are cheaper to build and maintain. Complexity is a tax you pay every day - in onboarding, debugging, and feature work. At Stratorys, we choose the straightforward solution that solves today's problem. We avoid clever abstractions that hide what is going on. We avoid deep inheritance-style hierarchies and over-generic designs.

This does not mean "cut corners." It means prefer clear, direct code and plain data flows. If a feature needs only one storage engine, we implement that one. If we later need more, we extend with real knowledge. The test for simplicity is whether a new teammate can read the code and explain it back in plain words within minutes.

YAGNI: you aren't gonna need it

We build the smallest slice that delivers visible value to users. We do not add features "just in case." Most speculative work becomes waste because real requirements change. Shipping the thin vertical cut forces us to learn from actual usage and keeps our code small. When new needs arrive, we refactor from a clean base instead of dragging around half-finished ideas.

A practical example is payments. Start with one provider and a basic flow that meets today's needs. Only after real customers demand more do we add a second provider or advanced failure handling. By then we understand the shape of the problem and can design the right extension points. Yagni expands on this.

Dependency inversion and interface segregation

These are two of the five SOLID principles. The core of the system should depend on interfaces, not on concrete details. In everyday terms: business logic should talk to "a thing that can load and save users" rather than "Postgres using library X." This makes it trivial to swap implementations, run fast tests, and keep the business code free from infrastructure concerns.

Keep interfaces small and focused. Split reading and writing responsibilities if callers only need one side. Avoid "fat" interfaces that force a caller to know about operations it never uses. You will know you have the right size when you can explain an interface in a single sentence and when swapping a dependency does not cause a cascade of changes.

Constraints and boundaries

Limit everything in space and time

Unlimited operations become production incidents. We put limits everywhere. Network requests and database calls have timeouts. Connection pools have maximum sizes. Queues, batches, and API responses have clear caps. Uploads have body size limits. Background jobs process fixed pages of work. With limits, the system fails fast and recovers; without them, it stalls and takes everything down with it.

A simple way to check this principle is to look for defaults. If you see "no limit" or "infinite retry," you are one outage away from a bad day. If you see explicit numbers - like a two-second timeout or a thousand-item page size - you can reason about load and backpressure.

Explicit over implicit

Surprises erode trust. We make behavior obvious where a function or endpoint is called. If something sends an email, starts a background job, or updates multiple subsystems, the caller should ask for it clearly. We avoid hidden side effects, silent conversions, and "magic" defaults that change behind your back.

Explicit code is sometimes more verbose, but it is predictable. It is also easier to review because the intent is visible. If you ever find yourself asking "where did this job come from?" or "who changed that record?", there is probably an implicit behavior that needs to be pulled into the open.

Constrain first, relax later

Start strict. It is easy to loosen a rule later, but hard to tighten it without breaking callers. We use distinct types for different identifiers so they cannot be mixed up. We enforce business rules at construction time, not at random call sites. When we later need to accept more cases, we can relax those checks in one place.

The benefit of this approach is fewer "impossible states." If your types and constructors make illegal combinations unrepresentable, a whole class of bugs disappears. You will also find that tests become simpler because many invalid paths cannot even be written down. Alexis King's Parse, don't validate explores this idea in depth.

Put constraints upstream

Catch problems where they enter. Handlers validate request data before calling any workflow. Constructors enforce invariants so invalid objects never exist. Database schemas reject bad data using constraints rather than hoping the application always checks. All inputs are treated as hostile and sanitized before use. By pushing checks upstream, you prevent corrupt state from spreading deeper into the system, where it is harder to diagnose and fix.

A healthy sign is that most invalid requests fail quickly with a clear message and never touch the database. Another healthy sign is that when you attempt to insert bad data by mistake, the database refuses it with a precise error.

Resilience and safety

Fail fast, fail safe

Bad input should be rejected in milliseconds. Slow or failing dependencies should not drag your whole process down. We use timeouts to stop waiting, retries with backoff for transient errors, and circuit breakers to cut off failing downstreams. We isolate resources so one busy queue or one noisy neighbor does not starve everything else.

Failing fast is not about giving up - it is about protecting the system and the user. A fast, clear error is better than a spinning loader that times out after a minute and leaves unknown side effects behind. See the circuit breaker pattern for an introduction to resilience patterns.

Security by design

Security is part of the design, not a patch. We give every component only the access it needs and nothing more. We default to secure choices: encrypted connections, signed tokens, parameterized queries. We avoid custom cryptography and rely on proven libraries. The OWASP Top Ten is a good starting checklist.

You can feel this principle at work when a secret leak is contained to a small blast radius and when a compromised token expires quickly. For operational security rules - passwords, SSH keys, device security, and incident reporting - see the security policy.

Stay consistent in errors

Error handling follows the same patterns everywhere. Clients see stable error codes and clear, human-readable messages. They never see stack traces, SQL fragments, or file paths. Engineers get full details in logs and traces, linked by request or correlation IDs so an error can be followed across services.

Consistency pays off in support and observability. When the code and the dashboards speak the same language for errors, you can triage faster. When clients rely on stable codes, they can build robust behavior without chasing wording changes. RFC 9457 (Problem Details for HTTP APIs) is a good standard to follow for structured error responses.

Operational principles

12-factor app sensibilities

We build disposable, stateless processes that can scale out. Logs are event streams collected by separate systems rather than files living on the host. We build once and deploy the same artifact to different environments. Configuration comes from the environment and config files, not from hardcoded values. Startup is quick and shutdown is graceful so rolling updates are safe.

This approach makes capacity planning and recovery straightforward. You can add instances when load rises, and you can replace instances when something looks unhealthy, without special steps.

All constants must be configurable

Magic numbers turn into midnight pages. Anything that controls behavior at runtime must be configurable: timeouts, retry counts, page sizes, external URLs, and feature flags. Different environments need different settings. Tests need isolation. Developers need local overrides. When these values are in configuration, you can adjust them safely without changing code.

A good practice is to keep sensible defaults but allow every value to be overridden. You should be able to answer "what happens if we halve the timeout?" without a rebuild.

Code is the source of truth

Documentation can drift; code cannot. We record the "why" of big decisions in short Architecture Decision Records so teammates can understand the trade-offs later. We let the code express the "what" with clear names, types, and tests. We keep docs close to the code and keep them short. A well-named function or type often removes the need for a long comment.

You will notice this principle working when new people learn the system by reading the code and its tests first, and only then check ADRs for deeper context.

Architecture strategy

Library-first approach

When many parts of the system need the same logic, we prefer a shared library over a new service. A library avoids extra network calls, encoding overhead, and deployment coordination. It also keeps development fast because changes stay within one process. We promote a library to a service only when we truly need independent scaling, hard isolation, or different teams to move at their own pace.

A tell that you should stay with a library is when the main reason to split is "code organization." That is a weak reason. A tell that you should split into a service is when the team and scaling profile are genuinely different from the rest of the system.

Small, cohesive services

If we do choose microservices, each one owns a full vertical slice: its data, its rules, and its deployments. We do not build services that must talk to three other services to complete a simple request. Chatty designs and distributed transactions are monoliths spread over the network; they inherit the complexity of both worlds with the benefits of neither.

A cohesive service has a clear purpose, a small set of well-designed APIs, and minimal knowledge of the internals of its neighbors. It can be deployed independently without orchestration games.

Prefer declarative over imperative

We describe desired outcomes instead of spelling out step-by-step instructions. SQL describes what rows we want, not how to loop over them. Migration files describe the new shape of the schema. Infrastructure-as-code describes the final state of cloud resources. Configuration describes runtime behavior. Declarative artifacts are easier to review, test, and reason about because intent is front and center.

A simple way to apply this is to ask, "Can this be described as a desired state?" If yes, write it declaratively and let tools perform the steps. You will spend less time debugging scripts and more time understanding the system.


Final note. The best software is boring in the best way: simple, explicit, well bounded, and safe by default. These principles give you a compass. Use them to keep codebase easy to maintain and reliable in production. When you need to bend a rule, do it with intent, capture the reasoning, and make sure the benefits outweigh the extra complexity you introduce.