As systems grow and teams break monoliths into dozens of small services, they often lose one of the most important engineering capabilities: the ability to run a single service locally and have it behave like it does in the real environment. The runtime environment becomes so cloud-dependent that “local development” degenerates into a set of mocks, stubs, or a 20-container docker-compose file that barely resembles production.
Free Link
They often create tightly-coupled systems where nothing works outside the full cluster. This post is about how to design microservices so that each one can still run locally, independently, using real code paths not mocks, even when the full system has dozens of services, managed secrets, cloud resources, or external dependencies.
It’s not about running the whole system locally. It’s about ensuring each service still has a local mode that behaves correctly.
A service that can run locally has several advantages:
Developers can debug with real logic, not stubs.
New hires can onboard without spinning up the entire cluster.
Regression testing becomes simpler.
Service boundaries stay honest: if a service needs more than a few dependencies to start, that’s usually a design smell.
Iteration speed remains high.
The key principle:
“Local mode should be a real mode, not a fake mode.”
If the service calls Redis in production, local mode should also call Redis but just a local instance.
If it uses S3, local mode should use LocalStack.
If it talks to internal Service A, local mode should allow a local substitute or a toggle to call the real remote service.
Local mode is just another environment.
Environment Design: The Core Rule
Most teams define:
devstageprod
But the correct list should be:
localdevstageprod
And local is first-class.
That means it gets its own:
- config file
- secrets file
- dependency configuration
- startup path
The only difference is that its dependencies are local equivalents (like localstack instead of S3, local Postgres instead of AWS Aurora, etc).
Local shouldn’t be “special.” It should be treated exactly like dev/stage/prod, but pointed at local equivalents.
Handling Internal Service Dependencies:
Handling internal service dependencies is usually where “local mode” becomes messy. One service calls another, which calls another, and suddenly running a single service locally requires spinning up a whole chain. That’s when teams default to mocks, and once that happens, local behavior stops reflecting real behavior.

A better approach is simple: each service should be able to either talk to the real remote dependency or fall back to a minimal local substitute. Which one it uses is decided purely by configuration. For example:
If SERVICE_B_URL is defined, Service A calls the real Service B. If it isn’t, Service A starts a small in-memory version that covers the essentials.
The part that makes this actually usable is having a shared dev or stage cluster where all services run in a predictable environment. Local developers can point to that cluster for anything they don’t want to run on their machine. That way, nobody needs to start ten services just to test one.

The local fallback mode is meant for dependencies that are easy to simulate or aren’t crucial to the thing we’re currently working on. The dev cluster handles everything else. As long as both options exist, a developer can run exactly one service locally and still test the real integration paths end-to-end. That’s the goal.
Example in Go:
Our config struct might look like this
type Config struct {
Env string
RedisURL string
PostgresURL string
S3Endpoint string
ServiceBURL string // remote dependency
}This structure defines everything the service needs to talk to the outside world.
The service should look at exactly one environment variable: ENV.
Everything else comes from a config file corresponding to that environment.
func LoadConfig() (*Config, error) {
env := os.Getenv("ENV")
if env == "" {
env = "local" // local is the default, not an afterthought
}
viper.SetConfigName(env) // local.yaml, dev.yaml, stage.yaml, prod.yaml
viper.AddConfigPath("./config") // folder containing all env configs
viper.AutomaticEnv() // allow overrides if needed
if err := viper.ReadInConfig(); err != nil {
return nil, err
}
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}This gives us a single, predictable, unified rule:
ENV=dev → config/dev.yaml
ENV=stage → config/stage.yaml
ENV=prod → config/prod.yaml
(no ENV) → config/local.yamlExample config/local.yaml
env: local
redisURL: "redis://localhost:6379"
postgresURL: "postgres://local:local@localhost:5432/app"
s3Endpoint: "http://localhost:4566"
serviceBURL: "http://dev-cluster/service-b"Local development dies when “local” stops being a real environment. The moment a service can only run inside a full ephemeral cluster, the team loses the fastest feedback loop they have.
The fix isn’t complicated: treat local as a first-class environment with its own config, its own dependencies, and the ability to reach either local substitutes or shared dev/stage services.
If every service can boot with:
ENV=local- a complete
local.yaml - local infrastructure defaults (Redis, Postgres, LocalStack)
- remote dependencies (dev cluster URLs)
then engineers never get boxed into spinning up half the company just to test one feature. We preserve real code paths, real integrations, honest boundaries, and the ability to debug quickly.




