Emit State, Let the Consumer Decide
Designing a loan delinquency trigger taught me two things about contracts between services: send state and let the consumer own the decision, and read the parent design before the ticket. A shared helper taught me a third.
Most of the bugs I've seen between services aren't in either service. They're in the agreement between them: what one side sends, what the other side assumes it means, and who gets to decide what happens next.
Two pieces of work on a lending platform made this concrete for me in the same week.
The problem: telling the CRM a loan is late
We offer lines of credit through a loan-servicing platform. That platform tracks days past due (DPD) for each line. Our CRM runs the customer journeys, including what happens when someone falls behind on a payment.
The business rule is simple on paper. Zero days past due is current. More than zero is delinquent. The CRM needed to know when that changed.
My first design: emit the transition
I designed it from the ticket. A nightly job, scheduled a couple of hours after the servicing platform rolls its DPD numbers over, compares each line's DPD to the previous run. When a line goes from 0 to 1 or more, emit a delinquent event to the CRM.
I also sized two ways of getting the data. Using the platform's API would cost one or two calls per line of credit per night. Reading its database replica directly would be one query. We went with the replica, and waited on access before building.
It was a clean design for the ticket I'd read.
What product review caught
The ticket was one piece of a larger design for collections. When product reviewed my approach, it was clear I'd skipped that collections journey entirely.
One example: a closed line of credit can still carry a past-due balance. My design only cared about a line crossing from 0 to 1+. It had nothing to say about a line that was already late and getting later, or one that came back to current, or a closed line still owing money.
I paused the build. We worked through it in a short huddle with the lending and CRM folks, and I sent the result back to product before writing any more code.
The contract we agreed on
The change in thinking was this: our service shouldn't decide what the CRM does with a late payment. It should report the state of the loan, accurately and every night, and let the CRM own the journey.
The sweep reads DPD from the replica nightly and emits one of three events:
loc.delinquentwhen a line becomes past dueloc.dpd_updatedwhen a line that's already past due changesloc.currentwhen a line is back to zero
Each event carries a journey stage, the amount past due, and the servicing platform's own delinquency date, so the CRM doesn't have to recompute anything. Closed lines stay in the sweep for as long as they're past due.
After reviewing that contract, product asked for one more thing: the line's status and sub-status from the servicing platform. That went in as a follow-up.
The nice side effect of this shape is that most future changes to collections live in the CRM. If they want a reminder at 5 days and an escalation at 30, that's their rule, applied to state we already send. Our side doesn't need a new event for every new idea.
Read the parent design before the ticket
The lesson I wrote down for myself was simple. I designed from the ticket and missed the larger design it belonged to.
Catching it in review was cheap: one huddle and a revised write-up. Catching it after the CRM team had built their journeys on top of a delinquent-only event would have meant changing a contract two teams depend on.
Now, before I design anything that crosses a service boundary, I find the parent epic or design doc and read it first. The ticket tells you what to build this sprint. The parent tells you what the thing is for.
The shared-method trap
The same week, a different kind of contract bit me. Not between services, but between two features inside one.
Earlier, I'd added a flag sent to our credit risk team on an offer refresh, telling them whether a member had ever been a customer of the new lending product. It reused an existing helper that answered roughly "is this member a customer of the new lending product?"
Later, a separate routing change needed a narrower answer: only count an offer while it's live, or once an application has used it. That's the right rule for routing. A member with an expired, unused offer shouldn't be sent to the new app.
The routing change narrowed the shared helper. Which quietly changed the credit risk flag too.
I found it while answering a QA question. The flag now read false once a member's offer expired, which is exactly the refresh case the flag exists for. Nothing failed, no test went red, and no member had been affected yet, because the first live offers weren't due to expire for about two months. It would have been a silent data problem the first time it mattered.
The fix: split it and guard the split
The fix gave credit risk its own "ever had an offer" check and left routing alone. Two questions, two methods.
Then I added a test that fails if anyone merges them back. Something like:
it 'keeps the credit risk flag independent of routing' do
member = create(:member, offer: create(:offer, :expired))
expect(member.routes_to_new_lending_app?).to be(false)
expect(member.ever_had_lending_offer?).to be(true)
end
The test isn't really about either method. It documents that these two answers are allowed to disagree, which is the thing someone tidying up duplicate-looking code would otherwise miss.
Takeaways
- Emit state, not decisions. Send what's true about the record and let the consumer own what to do about it. Your contract changes less often that way.
- Include enough context that the consumer doesn't have to call you back. Amount, stage and the source system's own dates belong in the event.
- Read the parent design before the ticket. Gaps found in design review cost a conversation. Gaps found after another team builds on your contract cost a migration.
- A shared method is a shared contract. Before you narrow a helper for one caller, check every other caller and ask whether they meant the same thing.
- When two features need different answers, split the method and pin the difference with a test. Duplicate-looking code that's meant to differ needs something that says so.