Sitemap

Becoming a better programmer: an internal Vula memo

7 min readSep 20, 2025

Vula is moving from working app to scaling app. This adds a lot of new challenges that all of us on the team need to rise to. Part of this is to call out clearly paths we should all be on.

I recently read Modern Software Engineering by David Farley (thank to a different David for the suggestion) and agreed with so much of it and felt this was a good springboard for how I am viewing Vula.

Press enter or click to view image in full size

1. You are not crafting code

Craft is just making things with skill and care.

Engineering is a science. As the book puts it “finding efficient economic solutions” but even this misses for me. It is repeatedly finding these efficient and economical solutions. Repeatedly because it needs to scale.

This needs skill and craft, but it more than just that. As an example, this is a simple flow, the (initial) customer would be happy, and it is easy to read but is not easy to scale.

# silly simple example Claude made:
def process_user_payment(user_id, amount, card_details):
user = db.query(f"SELECT * FROM users WHERE id = {user_id}")
if user['subscription_type'] == 'premium':
discount = 0.1
else:
discount = 0

final_amount = amount * (1 - discount)

# Direct Stripe API call
charge = stripe.Charge.create(
amount=int(final_amount * 100),
currency="usd",
source=card_details,
description=f"Payment for user {user_id}"
)

# Update database
db.execute(f"INSERT INTO payments VALUES ({user_id}, {final_amount}, {charge.id})")

# Send email
send_email(user['email'], "Payment received")

return charge.id

Instead, lets focus on scaling. Yes it is more code, but it is better and worth doing and with modern compilers is not going to cost any performance at all.

class PaymentProcessor:
def __init__(self, payment_gateway, user_repository, notification_service):
self.gateway = payment_gateway
self.users = user_repository
self.notifications = notification_service

def process_payment(self, user_id: str, amount: Decimal) -> PaymentResult:
user = self.users.get(user_id)
final_amount = self._calculate_final_amount(user, amount)

result = self.gateway.charge(user, final_amount)

if result.success:
self._notify_success(user, result)

return result

def _calculate_final_amount(self, user: User, amount: Decimal) -> Decimal:
discount = self._get_user_discount(user)
return amount * (1 - discount)

def _get_user_discount(self, user: User) -> Decimal:
# This logic is now testable in isolation
if user.subscription_type == SubscriptionType.PREMIUM:
return Decimal('0.1')
return Decimal('0')

Notice the differences? The engineered version:

  • Has clear boundaries and responsibilities
  • Can be tested at multiple levels
  • Doesn’t know about specific payment providers (Stripe could be swapped for something else)
  • Has measurable quality metrics (we can count test coverage, measure performance of each component)
  • Can be modified without fear (change the discount logic without touching payment processing)

Moreover, with our LLM tools we can actually have multiple nudges to write in this way more often. And going even further Farley prompts you: “if your function is more than 30 lines, is it abstracted enough?”

Then when you are done with the first draft ask some questions.

  • Not “does it work?” but “can we prove it works?”.
  • Not “is it fast to write?” but “is it fast to change?”
  • Not “do I understand it?” but “will the next person understand it?” (remember our coding standards)

Yes it does mean slowing down in the short term to speed up in the long term. But if we want to build something that lasts, something that can grow with us, we need to make this shift. Now we have the stable platform we have, we have the time to make things much more reliable — we didn’t have this luxury before and this made things harder.

2. Test-Driven Development: Write the Test First

Write the tests first. What should this thing do? Literally write the test file in the ticket or at the beginning of creating the branch.

def test_subscription_pricing():
# First, define what we actually want
assert calculate_price(1, "basic") == 10
assert calculate_price(10, "basic") == 100
assert calculate_price(11, "basic") == 99 # 10% discount
assert calculate_price(51, "basic") == 433.50 # 15% discount

assert calculate_price(1, "pro") == 25
assert calculate_price(100, "pro") == 2500
assert calculate_price(101, "pro") == 2020 # 20% discount

# Enterprise is flat rate
assert calculate_price(1, "enterprise") == 50
assert calculate_price(1000, "enterprise") == 50000 # No discount

Doing this means we hold all the use cases in our head first, and then plan a method, rather than making the first happy case method and then adding the modifications of other paths.

It also is a form of documenting the requirements of the feature at the beginning of its life, making changes to requirements easier in the future.

This is what moves us from crafting code, to engineering code.

When writing these tests, think about tests that should go red as well as those that should go green, and make these tests very easy to run — as you want to be running them on every commit!

We as a team have been really good at iterating and working in MVPs. We need iteration. But we also need to be building on the shoulders of the work we did before, not going backwards before we go forwards.

For your next feature ticket, I challenge you to start with TDD.

3. Dependency Injection Makes Testing Possible

You can’t test code that creates its own dependencies:

class Car:
def __init__(self):
self.engine = V8Engine() # Can't test with different engine

def start(self):
self.engine.ignite()

Inject dependencies instead:

class Car:
def __init__(self, engine):
self.engine = engine

def start(self):
self.engine.ignite()
# Now you can test
def test_car_starts():
mock_engine = MockEngine()
car = Car(mock_engine)
car.start()
assert mock_engine.ignite_called

This isn’t just about testing. It’s about flexibility. Tomorrow you can swap in an electric engine without changing Car. Imagine instead of engines, it is application or user data.

4. Abstract and separate concerns

Think about adding an item to a cart.

You would need to connect to a db, insert the item, recalc the total from the items in the cart, and update the users screen. This could be written as one procedural function. But it shouldn’t be.

The db queries, the cart logic, the calcs of totals, this should all be abstracted.

def add_to_cart(item_id, user_id):
cart = Cart(user_id)
cart.add_item(item_id)
store.save_cart(cart)
total = cart.calculate_total()
return total

Is this optimal? Farley says no. The above is still a bit procedural.

def add_to_cart(item_id, user_id, listener):
cart = Cart(user_id)
cart.add_item(item_id)
listener.notify('item_added', {'cart': cart, 'item_id': item_id})
return cart

Wait, where did the database save go? Where’s the total calculation? That’s the point.

The job of add_to_cart is to add an item to the cart. That’s it. Other systems that care about this event (storage, totals, notifications) register as listeners:

class StorageListener:
def on_item_added(self, event):
self.store.save_cart(event['cart'])

class TotalCalculator:
def on_item_added(self, event):
total = self.calculate_total(event['cart'])
self.cache.set(f"cart_total_{event['cart'].user_id}", total)

class EmailNotifier:
def on_item_added(self, event):
if self.user_wants_notifications(event['cart'].user_id):
self.send_cart_update(event['cart'])

Don’t have functions that do X and Y (add to cart and update the total).

I find this very hard. I am sure others do too. Let’s support each other in code review and ask more of our LLMs when doing aided personal reviews.

Another tip from Farley — don’t call external services directly. It will make it hard to abstract later. No more calling Dropbox or Sharepoint directly. Call ExternalStorage.

Finally, again, if your function is 30 lines long, is it abstracted enough?

5. Microservices and Coupling

Farley talks about five types of coupling to watch for when moving from a monolith, to a modular mono, and eventually microservices:

  1. Operational coupling: Service A dies if Service B is down
  2. Developmental coupling: Change Service A, must also change Service B
  3. Semantic coupling: Both services must agree on what “customer” means
  4. Functional coupling: Services share business logic
  5. Incidental coupling: They happen to use the same database table

Moreover, consider that microservices can create 9 ways to fail when a monolith has 2 in a simple A calls B scenario:

  1. Bug in A’s logic
  2. A loses network
  3. Request gets lost
  4. B loses network
  5. Bug in B’s logic
  6. B crashes before responding
  7. Response gets lost
  8. A loses network before receiving response
  9. A can’t process B’s response

The solution will be queues — probably Kafka one day.

6. Tiny tiny changes!

Farley’s rule: any change should be deployable to production within one hour of commit.

One hour. Not one sprint. Not one week. One hour.

This sounds insane until you realize what it forces:

  • Changes must be tiny
  • Tests must be automated
  • Deployments must be safe

This I am sure you will agree is normal for us. A feature branch with 47 files changed, 2000+ lines, worked on for 2 weeks, sent for review.

Here’s what Farley and now I suggest:

# Monday 10am: Add new database field (deploy)
# Monday 2pm: Add API endpoint that returns empty (deploy)
# Tuesday 9am: Add business logic, feature flag off (deploy)
# Tuesday 11am: Add tests for business logic (deploy)
# Tuesday 3pm: Wire up the UI, still flagged off (deploy)
# Wednesday: Turn on for internal testing via partner portal config
# Thursday: Turn on for some users using partner portal config

Yes the code has been deployed on Wednesday but no-one can use it. That is fine. The savings are in the ease, speed of iteration, and identification of issues.

The biggest blocker to us here is the manual testing work we need to do, but doing the above TDD will let us be more confident with our deployments and hence more comfortable releasing often.

Summary: What Actually Matters

Here’s what I think we should focus on:

  1. Write tests first — Start with TDD on your next feature
  2. Make functions do one thing — No “and” in descriptions
  3. Use your LLMs to help you — “could this be more SOLID/abstracted?”
  4. Keep functions under 30 lines — It’s a forcing function for abstraction
  5. Don’t call external services directly — Always use an adapter
  6. Ship tiny changes — If your PR has 500+ lines, it’s too big
  7. Inject dependencies — Pass things in, don’t create them inside

We don’t need to do all of this tomorrow. But we need to start moving this direction.

Pick one thing. Try it this week. See if it makes your code better.

Crafting is for minecraft .We should be doing more engineering and less crafting.

--

--

No responses yet