Delve 23: Let's Build a Modern ML Microservice Application - Part 10, Improving DevX with AI

"Code is read much more often than it is written." - Guido van Rossum
Greetings data delvers! It has been a while since we last delved into modern ML microservices. In part nine of this series we looked at optimizing our docker container setup. Since then, AI of course has become a much more prominent tool in the software engineering space. While there has been much commentary about how AI can lead to developer burnout, for this delve I want to take a look at how AI can actually improve the developer experience!
DevX
When starting a new project, the Developer Experience (or DevX) is usually not top of mind. The focus is on "ship it" without much consideration for how the code will be maintained over time. This leads to many issues that negatively impact the experience of working with the code: poor documentation, cryptic build processes, technical debt, missing tests, etc. In the era of AI vibe coding, this problem can become even worse with the volume of code being produced. However, just as AI can be a tool to push slop, it can also be very useful in taming the very issues that come from it. Let's dive in and see how.
The Setup
I'm starting with the part nine state of my Modern ML Microservices repo. While I hope this repo is pretty straightforward it has a few drawbacks:
- Lack of clear instructions on how to run the project - You have to remember all of the
uvandpytestcommands to run everything - No automated linting - While we have ruff available, we need to remember to run it
- Sparse README - Pretty self-explanatory.
This mirrors what I typically find neglected in most projects. Since none of these issues are on the critical path to getting the software functional, they are deprioritized. A perfect candidate for improvement with AI.
Enter... the AI!
For this particular project, I decided to use the Gemma4 quants available from Unsloth. Since this isn't a purely coding-focused task, I wanted a more general-purpose model. My full setup script is available below:
If you want a full breakdown of how to use this script and connect it to Claude Code check out the previous delve!
Note
One of the benefits the unsloth quant provided was the ability to enable Multi Token Prediction (MTP) with the --spec-type draft-mtp flag. This resulted in a pretty significant speed up in token generation for me.
Just Do It!
To tackle the first problem of having to remember commands, we can introduce just as a command runner tool. Just is inspired by make but designed specifically as a command runner, not a build tool, meaning it can avoid some of the complexities of the original make. I find it extremely useful to alias commands in projects so I don't have to remember all the syntax. For example, we can add a justfile to the root of our project with the following contents:
And instead of having to remember the specific command to invoke ruff, we can execute just lint in our shell. Pretty neat!
There are many aliases we could add like this that would be useful, however writing all of these by hand would take a while. Instead, let's ask AI to do it!
Claude
Create a justfile for this repo with standard commands such as installing or updating all packages, building workspace packages, spinning up and tearing down the docker containers, running tests (optionally with coverage or generating an html report), linting, lint fixing, formatting the code and running a format check. Include emoji echo outputs for each command.
You can iterate back and forth with Claude to configure it how you like it, but I ended up with something like this:
Now when I open a shell in my project and execute just I get a nice list of all the commands I have available to me!
just
Available recipes:
build # Build workspace packages
check # Run tests, linting, and formatting check
clean # Clean local build artifacts
clean-all # Clean everything
clean-docker # Clean Docker environment
default # Default command: List all available commands
down # Tear down Docker containers
format # Format code
format-check # Check formatting
install # Install all dependencies
lint # Lint code
lint-fix # Lint and fix code
test # Run tests
test-cov # Run tests with coverage
test-html # Run tests and generate HTML coverage report
up # Spin up Docker containers
update # Update all dependencies
Before you Commit to AI: pre-commit!
We now have a bunch of convenient commands for linting and fixing our code, but we still have to remember to run them before committing and pushing. Let's fix that with pre-commit.
Pre-commit does exactly what it sounds like. It executes a hook before git commit finishes running. Let's ask Claude again to add this to our repo.
Claude
Set up ruff with precommit https://github.com/astral-sh/ruff-pre-commit
Tip
I often paste the url to docs straight into my prompts so Claude can reference them for me.
Doing this should generate something like the following in the project root:
| .pre-commit-config.yaml | |
|---|---|
Now when we run git commit our code will be automatically linted and formatted for us!
Note
This works for Claude as well so it's a good way to ensure any auto generated code is properly formatted too!
Pre-commit requires installation commands in order to use it. Let's ask Claude to add them to our justfile.
Claude
Add commands to the justfile for running pre commit hooks, and installing pre commit
This generates something like the following:
| justfile | |
|---|---|
The Best Diagramming Tool for AI: Mermaid 🧜♀️
Currently there are no architecture diagrams in the repo to show how the different layers fit together. Instead of drawing one by hand, let's ask Claude to use Mermaid to create one for us. Mermaid has quickly become my go-to diagramming tool. It has a number of benefits mostly stemming from its markdown-inspired syntax, allowing AI to easily write and read mermaid diagrams within its context. It is also natively rendered in VSCode and GitHub. Let's give it a try!
Claude
In the main readme include a mermaid diagram that details the architecture of the project
This created the following Markdown in the project Readme:
```mermaid
graph TD
Client[Client] --> Orchestrator[Orchestrator Service]
subgraph "Orchestrator Service"
Orchestrator --> Service[Pricing Service]
Service --> Provider[MLFlow Model Provider]
end
Provider --> MLFlow[MLFlow Server]
MLFlow --- Models[(Model Registry)]
```
Which renders like so:
graph TD
Client[Client] --> Orchestrator[Orchestrator Service]
subgraph "Orchestrator Service"
Orchestrator --> Service[Pricing Service]
Service --> Provider[MLFlow Model Provider]
end
Provider --> MLFlow[MLFlow Server]
MLFlow --- Models[(Model Registry)]
Tip
This is a very simple example, but I have found mermaid diagrams + AI to be extremely useful for both diagramming and exploring codebases. If you are confronted with an unfamiliar codebase, ask Claude to generate a diagram. It is a fast way to build mental context.
Let's Refactor
Now that we have some documentation in place, let's try out Claude for some refactoring work. The current way config values are loaded is somewhat confusing. They could come from the project config.yaml, but they could also be overridden by environment variables. Let's make this more explicit. Instead of loading config values directly from the environment, let's modify our config loader to only read them from config.yaml but explicitly allow overriding certain variables with a ${value:default} syntax. This gives us more control over which variables can and cannot be overridden via environment variables, with an optional default fallback. Importantly, this also makes config.yaml the single source of truth for all configuration settings.
Since this is a bit of a larger task, let's use the plan mode in Claude to have it give us a plan document to approve rather than just jumping in.
Claude
Create a plan to refactor the config loader to only read config values from the yaml file, however, the yaml should support defining parameters in the form of ${value:default} to load environment variables
This gave me the following plan:
Claude Plan
Objective
Refactor the configuration loader inhousing-price-orchestrator to exclusively read configuration values from the config.yaml file. To maintain flexibility, the YAML file will support a ${VAR_NAME:default_value} syntax that dynamically resolves environment variables at runtime.
Current State
- TheConfig class in src/shared/config/config_loader.py currently uses pydantic_settings with multiple sources: init_settings, env_settings, dotenv_settings, file_secret_settings, and YamlConfigSettingsSource.
- This means Pydantic automatically attempts to populate fields from environment variables and .env files, bypassing the YAML file for those values.
Proposed Changes
1. Restrict Configuration Sources
Modify theConfig class in src/shared/config/config_loader.py to stop automatically loading environment variables.
- Action: Update settings_customise_sources to remove env_settings and dotenv_settings.
- Result: Pydantic will now only populate the configuration fields from the YAML file (via YamlConfigSettingsSource) and secrets, effectively making the YAML the primary source of truth.
2. Implement Environment Variable Resolution via model_validator
Add a model_validator to the Settings class to dynamically parse and resolve the ${VAR:default} syntax before Pydantic performs its internal validation.
- Action: Define a @model_validator(mode='before') on the Settings class.
- Logic:
- The validator will receive the raw dictionary of values parsed from the YAML.
- It will recursively traverse the dictionary to find any string values.
- It will use a regular expression to identify strings matching the ${VARIABLE_NAME:default_value} pattern.
- It will replace these strings with the actual environment variable value (falling back to the provided default if the environment variable is missing).
- Benefit: This approach keeps the configuration logic clean and separates the "loading" of data (YAML) from the "resolution" of dynamic values (Environment variables).
3. Define Parsing Logic
Within the validator, the following logic will be implemented: - Regex: User"\$\{([^:]+)(?::([^}]+))?\}" to capture the variable name and the optional default.
- Resolution: Use os.getenv(var_name, default_value) to retrieve the final value.
- Recursion: Ensure that if the YAML contains nested dictionaries or lists, the validator still reaches and resolves placeholders inside them.
4. Update config.yaml
Update the config.yaml file to demonstrate the new interpolation syntax.
- Example: pricing_model_url: ${MODEL_URL:http://default:8080}.
5. Verification and Testing
- Unit Tests: Create new tests intests/unit/ to verify:
- Literal values: Ensure strings without ${} are not modified.
- Interpolation: Verify that a value like ${DB_URL:localhost} correctly picks up DB_URL from os.environ.
- Default Fallback: Verify that if DB_URL is missing, the value falls back to localhost.
- Nested Resolution: Ensure placeholders inside nested YAML structures are correctly resolved.
Implementation Steps
1. Modifysrc/shared/config/config_loader.py to remove automatic environment settings and add the model_validator to the Settings class.
2. Update src/shared/config/config.yaml to use the new ${VAR:default} syntax for dynamic values.
3. Run the unit tests to ensure the refactoring works as expected.
4. Run just lint to ensure code quality standards are met.
Notice how Claude is now planning to take advantage of our just commands to ensure that the linting is correct? Our tooling doesn't just help us, it helps Claude as well! After approving this plan, it took a bit of back and forth with Claude, but let's look at the changes that were ultimately made.
Config File Changes
Our config file will only need a simple change to follow the new syntax and enable the explicit env var override:
| housing-price-orchestrator/src/shared/config/config.yaml | |
|---|---|
Note
I also added a local environment specifically for local configuration.
Config Loader Changes
The biggest set of changes took place in the config loader.
First, we are going to need a few more imports:
| housing-price-orchestrator/src/shared/config/config_loader.py | |
|---|---|
Next, Claude generated a recursive environment variables resolver function for us:
Next, we can use a trick with Pydantic Model Validators to execute this function before Pydantic validates the field:
| housing-price-orchestrator/src/shared/config/config_loader.py | |
|---|---|
Lastly, we need to modify our config base class to prevent it from loading values from environment variables directly (and to add our new local environment).
Config Tests
As part of its plan Claude will generate unit tests for our new loader behavior as well (ensuring we have good test coverage of this new behavior from the beginning):
With AI, there's no excuse not to have unit tests!
Tip
After these tests are written, prompt Claude to run them (if it doesn't already) to ensure they all pass. If they don't, Claude will then debug the tests and figure out why.
Local Environment Wrap Up
In order to use our new local environment, we need to make a couple final small changes.
Create an .env file in the project root with the following contents (this could be a good place to put local secret values as well in the future):
| .env | |
|---|---|
And update our docker compose to use this variable:
Refactor complete!
Dr Claude
While doing this refactor, I had to prompt Claude a few times to make sure it followed the conventions of the repo. A good way to enforce this going forward is to create a CLAUDE.md file in the project root. This will get injected every time we run Claude, so it's best to keep this file small to preserve our context.
Instead of writing this file ourselves...
Claude
Add a CLAUDE.MD file documenting this project including the build and test just commands available, the architecture and directory layout, main repo conventions, and testing standards.
Which created the following:
Wrapping it Up
Hopefully this delve has shown just how useful AI can be to improve the developer experience and do some refactoring along the way! Full code available for this part here!
Note
Just for fun I asked Claude to improve the project Readme after writing this, you can see the results here!
Delve Data
- Developer Experience (DevX) is just as important as application functionality and should be considered from the start of a project
- Command runner tools like
justhelp standardize and simplify project workflows by aliasing complex commands - Pre-commit hooks automate code quality checks, ensuring consistent formatting and linting before every commit
- Text-based diagramming tools like Mermaid integrate naturally with AI workflows and version control
- AI-assisted refactoring benefits from clear project documentation and established tooling conventions
- A
CLAUDE.mdfile helps enforce project standards and improves AI-assisted development over time