./blog/expanding-the-bahala-calendars-functionality-breaking-your-applications-convention
← back to blog

Expanding the Bahala Calendar's Functionality - Breaking Your Application's Convention

Every codebase commits to patterns. Some are load-bearing; some are arbitrary. Some heuristics to differentiate between the two and how to introduce another.

  1. 1. Conventions: Load-Bearing or Otherwise
  2. 2. The Previously Established Convention
  3. 3. The Reevaluation
  4. 4. Implementation and Conceptual Groundwork
  5. 5. When to Deviate, In General
  6. 6. Closing
> Every codebase commits to patterns. Some are load-bearing; some are arbitrary.

I'll lay out some heuristics to differentiate between the two and how to introduce another.

Conventions: Load-Bearing or Otherwise

Every application has conventions the codebase commits to early and then quietly expects every future change to respect. The good patterns keep the program logic coherent. We cut down the effort dedicated to the "how would we structure this" discussions. With respect to program logic, the benefits are obvious: the read path is fast, there are fewer points of failure, etc.

Then there are some patterns that ossify around requirements which no longer apply, or around premises the application is set to outgrow. Telling them apart is half the work.

This post is about a new feature request on the Bahala Calendar where a heavily adhered-to convention was broken; what the convention was, what prompted the break, and the heuristics I'd offer to confidently justify my decision.

The Previously Established Convention

Bahala's Calendar consolidates events from various upstream sources: Notion, the City of Santa Monica's events website, and various local community sites to surface them on one page at calendar.bahala.org. Our ETL is a DigitalOcean scheduled job tasked to run nightly. This Go container fetches from our sources, normalizes event data, and writes the result to a per-source SQLite file hosted by DigitalOcean Spaces which rotates the previous SQLite files with fresh data.

The load-bearing convention: a snapshot ETL with a read-only API.

  • One-Way Data Flow
  • Immutable Object-Stored Source of Truth
  • Low Operational Footprint
  • Isolated Per-Source Failure Handling

This is a good and load-bearing convention. Zero concern for runtime writes, stale reads, and real-time data freshness.

The Reevaluation

Then came RSVPs.

RSVPs were the first feature where the user, not the upstream, would be the source of new data. The lack of our existing framework's ability to accommodate our new requirements became obvious when reviewing what RSVPs require juxtaposed to what our existing snapshot model provides:

  • Real-time read-after-write. A user who just RSVP'd needs the next page load to reflect it. Snapshot rotation operates on a nightly cadence or when explicitly triggered.
  • Transactional writes. Our snapshot files live in object storage only replacing whole files. Persisting a single RSVP would mean downloading the entire SQLite, inserting one row, and re-uploading the whole thing per submission.
  • Bidirectional data flow. Events flow upstream → us. RSVPs flow user → us → Notion. Not conducive to the on-demand nature of looking at a constantly changing list of people data.
  • A new ownership model. For events, the API is a relay and communication mechanism; the upstream is the owner of the data. For RSVPs, the API is the authority.
  • External side effects. Snapshot writes are a silent file rotation. RSVP writes trigger a confirmation email, generate an ICS attachment, and mirror to Notion. The existing convention had no language for these additional effects.

These concerns make it apparent that this wasn't a mere "one more table" requirement. It was a different kind of storage with a different lifecycle, different ownership semantics, and level of communicability.

There's our first heuristic flag.
This new feature was inherently a kind-difference, not a degree-difference as were previous ETL additions.

Implementation and Conceptual Groundwork

Our first convention deviation: One-Way Data Flow. To satisfy our always-up-to-date requirement, we added a write path: a new SQLite file the API writes RSVPs into directly. Data now originates inside our system.

Naturally, this begs for a change in primary function for our object storage layer. Our second convention deviation: Immutable Object-Stored Source of Truth. The RSVP data source of truth moves to this new local SQLite file to satisfy our transactional write requirement.

We now have our always fresh, user origin, data on demand RSVP system built on two surgical convention deviations, and one tiny hit to Low Operational Footprint.

When to Deviate, In General

A few questions to ask:

Is the new requirement kind-different, not degree-different? "Same shape, more of it" — keep the convention. "Different ownership, different lifecycle, different consistency contract" — it's time to start drafting the deviation.

What's the cost ratio? Bending the existing convention to cover the new thing has a cost. Adding a parallel pattern has a cost. Compare them honestly.

Can you isolate the deviation? The best convention-breaking is contained: a parallel layer with a defined boundary. Otherwise that's just re-architecture.

Stay in the same idiom when you can. Soft breaks (same technology, different lifecycle) are far cheaper than hard breaks (new technology). Reach for the soft break first; only escalate when the soft break is impossible.

Closing

Load-bearing conventions are positive restrictions. They keep ordinary changes cheap and the codebase coherent until a new requirement arrives that the convention can't gracefully absorb.

That's the moment to ask honestly whether the convention still serves you. The cost of asking is much smaller than the cost of forcing the new requirement into the old shape and discovering, later, that the seams gave out.

When something doesn't fit the pattern, it's okay to establish a new one.