Traditionally, running a service locally in a services-oriented environment often meant dealing with missing dependencies, outdated configuration, and cryptic errors. We have to admit that local development at Attentive has historically been somewhat painful, but that’s changing. We have learned from previous attempts and have combined that with better tooling and patterns across teams to deliver a unified, buttery-smooth developer experience for our software engineers.
One command to run a service and its dependencies locally: btr up -s sample-service
In this post, I will walk through what’s changed, how it works, and the benefits teams are seeing.
Our team, Developer Experience (DX), focuses on improving the tooling, platforms, and processes engineers use daily to create, test, build and deploy their code to production. We run periodic surveys to gather data from all the developers in the company. We have identified that using the local development environment as one of the most painful tasks. Some of the challenges included inconsistent startup processes, lack of dependency mocking, resource contention, and poor performance.
Teams had built isolated fixes, but without broadly adopted standard definitions or patterns. This meant that a lot of testing happened in dev environments, which was time consuming and created bottlenecks.
A standardized, performant, and intuitive localdev platform:
We developed a new CLI tool called btr
(internally known as the butter tool) and unified tooling around Tilt to make starting a local stack as easy as a one-liner when you are within the monorepo:
btr up -s sample-service
Or when you are within the service directory, simply run:
tilt up
This command wires together the service, any mocks, data sources, and UI using Tiltfiles and reusable components. Here’s an example Tiltfile used for a typical Java service:
load('<path_to_shared_Tiltfile>', 'run_postgres', 'run_flyway', 'run_localstack', 'run_java_service', 'run_client_ui')
service_name = 'sample-service'
localstack_res = run_localstack(service_name + '/localdev/aws-init.sh', app_name=service_name, labels=[service_name])
postgres_res = run_postgres(init_dir=service_name + '/localdev/pg_db', labels=[service_name])
flyway_res = run_flyway('flyway_' + service_name, 'db_name', './src/main/resources/db/migration', resource_deps=postgres_res, labels=[service_name])
java_service_res = run_java_service(
service_name,
1234,
resource_deps=localstack_res + flyway_res,
labels=[service_name],
)
client_ui_res = run_client_ui(
gql_gateway_overrides=["GQL_SAMPLE_SERVICE_HOST"]
)
And this is what it looks like running in Tilt:
btr
CLI?One of our principles in the DX team is to make the life of developers as easy as possible so they can focus on solving problems for our customers instead of dealing with infrastructure and configurations. The btr CLI abstracts away the complexity of our local development environment without overloading Tilt with logic. It standardizes how services are started, enables domain-level orchestration, and lets us collect useful usage data such as command invocations and errors. Beyond localdev, we use `btr` for several other workflows—making it a single, unified interface for developers.
Tilt provides a lot out of the box: container orchestration via Docker, automatic dependency chaining between services, real-time log visibility, and a live UI for monitoring running components. Engineers can also share Tilt snapshots to collaborate on debugging issues—get a snapshot from Tilt UI and post it in Slack, it’s that easy!
And it’s not just backend engineers who benefit from Tilt. Frontend engineers can run the full Client UI stack or Storybook using a Tilt-managed setup that handles dependency management, GraphQL artifact generation, the Node.js web server, and type checking—all running simultaneously with a single command, and no conflicts or extra setup.
We expose many shared Tilt functions that make it easier to configure localdev for a service. These functions abstract away much of the setup complexity, allowing developers to focus on business logic rather than infrastructure details. For example:
run_localstack
— mock AWS (S3, SNS, SQS, etc.)run_wiremock
— stub upstream gRPC and REST callsrun_postgres
/ run_mysql
/ run_redis
— run common data stores in containersrun_flyway
— bootstrap databasesThis helps avoid complex infra setup and eliminates reliance on flaky or changing Pulsar/Kinesis environments during local development.
For services using Kinesis or Pulsar, filesystem-based event streaming can be enabled with:
run_java_service(..., enable_file_system_streaming=True)
Directories for streaming are automatically created. Producer services will output events to these directories, while consumer services will automatically pick up new files created or modified during runtime. Test events in JSON format can also be dropped in manually and will be consumed in real time.
Tilt orchestrates services, and btr streamlines multi-service setups using domain-level recipes. This enables full-stack environments, including backend services, databases, event streams, mocks, and UI, to run locally with a single command.
This is an example of a domain that can run multiple services locally:
---
domainName: someDomain
services:
- sample-service
- another-service
- awesome-service
To run the domain:
btr up -d someDomain
To include the UI:
btr up -d someDomain --withUI
btr up -d someDomain --withUI
When running multi-service localdev, gRPC and HTTP ports are automatically assigned and injected based on shared configurations for each service.
Here’s an example for a service configuration.
sample-service:
pathPrefix: sample-service
kind: app
runtimes:
- java
localdev-env-variables:
LOCALDEV_SAMPLE_SERVICE_HOST: localhost
LOCALDEV_SAMPLE_SERVICE_DEBUG_PORT: 1234
LOCALDEV_SAMPLE_SERVICE_GRPC_PORT: 5678
The environment values ensure that downstream services and the GraphQL gateway can dynamically connect to the local instance instead of the dev environment without the need to hardcode local addresses:
attentive.sample-service.hostname=${LOCALDEV_SAMPLE_SERVICE_HOST:<dev_url>}
attentive.sample-service.port=${LOCALDEV_SAMPLE_SERVICE_GRPC_PORT:<dev_port>}
Finally, btr stitches together service configurations and generates a dynamic Tiltfile that imports all services that run as part of a domain. For example:
load('<path_to_shared_Tiltfile>', 'run_client_ui')
REPO_ROOT_CODE = os.getenv('REPO_ROOT_CODE')
run_client_ui(gql_gateway_overrides=[])
load_dynamic(os.path.join(REPO_ROOT_CODE, 'sample-service/Tiltfile'))
load_dynamic(os.path.join(REPO_ROOT_CODE, 'another-service/Tiltfile'))
load_dynamic(os.path.join(REPO_ROOT_CODE, 'awesome-service/Tiltfile'))
We use Playwright for End to End tests, which can also be executed entirely locally using run_playwright_ui, with all services mocked and running locally:
run_playwright_ui(...)
Our Localdev implementation has been launched thousands of times across teams, totaling over 330,000 minutes of usage in a single month. The localdev tooling has transformed how engineers develop and test changes. Our teams have reported faster iterations, easier debugging, and greater confidence in their code. Here are some of the user feedback we've received:
Localdev is now used across several technical domains at Attentive. Developers regularly spin up entire domains, run full-stack services locally, and validate complex flows without ever deploying to the dev environment. Multi-service orchestration, local event streams, and real-time mocks make testing seamless.
We ran into plenty of technical challenges building this solution, many of which were specific to the Attentive ecosystem. But the biggest takeaways that really moved the needle—and made this project successful—go beyond the code.
Before writing a single line of code, we took time to deeply understand the problem. We dug into how services are built at Attentive, what previous localdev efforts had tried, and where they fell short. We compared different approaches, evaluated their trade-offs, and explored third-party tooling that could give us a strong foundation. That’s how we landed on Tilt. Instead of reinventing the wheel, we used an existing tool that got us 80% of the way there, and then built on top of it with Attentive-specific workflows.
We didn’t want to build in a vacuum. Our “users” are other engineering teams, and we made it a point to partner with them early and often. Through regular check-ins, feedback sessions, and direct collaboration, we were able to shape the tooling to meet real needs—not just what we thought those needs were. It kept us honest, cut down feedback loops, and helped us build something people actually wanted to use. We built better tooling together.
Great tooling doesn’t just work—it gets out of your way. We knew that if our localdev setup wasn’t easy to use, it wouldn’t get adopted. So we focused on making the experience as frictionless as possible. While some services do require a bit of configuration, we designed the setup to be composable and intuitive. Complex bits like mocking dependencies or spinning up databases are abstracted behind simple functions. We also invested in docs—lots of them—so teams could self-serve without needing constant help from us.
We will continue to invest in improving the localdev platform, focusing on simplifying the developer experience and supporting more workflows. Some of our planned improvements include:
And we’re just getting started! Sounds interesting? Come and join us, we're hiring!