9 minute read

I wanted to automatically post something to Twitter whenever a new article appeared on this blog.

This sounded like a small GitHub Actions job and perhaps twenty lines of code.

Instead, by the end of the day, I had released version 0.2.0 of a small event routing runtime called Hooksmith.

There is an obvious interpretation of this story where an architect encounters a tweet and responds by inventing an event system.

That is not quite how it happened.

The interesting part was not adding more and more abstractions. It was repeatedly finding responsibilities in places where I did not want them to live, and moving them somewhere else.

Jekyll has no memory

The first problem appears before Twitter enters the picture.

Jekyll builds are stateless. The site generator sees the repository as it exists for the current build, generates the site and exits. It does not remember which posts existed during the previous build.

There is no natural PostPublished event I can subscribe to.

That matters because a Jekyll post can already exist in the repository before its publication date. When a scheduled build runs later, the post becomes publishable even though no new commit necessarily introduced it at that moment.

So the real first problem was not “send a tweet”.

It was:

Given the current state of the site, can I determine that a post has become publishable during this build?

For my blog, that can be solved pragmatically. The publishing pipeline can inspect the posts and derive the fact that one of them should be considered newly published.

That logic is necessarily specific to the producer. It knows about Jekyll, front matter, dates and the way this site is published.

But once it has discovered what happened, why should any of the reactions need to know those things too?

Turn the observation into an event

The simplest implementation would have been to detect the post and immediately call the Twitter API.

Something conceptually like this:

find newly published post
        |
        v
     Twitter

But the detection and the reaction are two different responsibilities.

The publishing pipeline knows how to answer what happened?

Twitter only needs to answer what should I do about it?

So instead of making the detection code call Twitter, I can turn the observation into an explicit event:

page.published

With enough information attached to describe the source, subject and published page, the publishing mechanism can stop there.

That gives me a useful boundary:

Jekyll-specific detection
        |
        v
  page.published
        |
        v
     reaction

The component that knows how to observe a change does not need to know what should happen because of that change.

That sounds obvious when written down. It was also the point where the twenty-line script started becoming something else.

Twitter immediately stopped being special

Once there is a page.published event, Twitter is only one possible reaction.

I could also post somewhere else, call a webhook, invoke a Lambda function, send a Slack message, generate social copy or do something I have not thought of yet.

                  +--> Twitter
                  |
page.published ---+--> LinkedIn
                  |
                  +--> webhook
                  |
                  +--> something else

I did not want the code that detects a published Jekyll post to grow a dependency on every integration I might eventually add.

So now I needed somewhere to decide which reactions should run for a given event.

The first obvious candidate was GitHub Actions itself.

I didn’t want the workflow to become the application

GitHub Actions is already running the publishing process. It can evaluate conditions, run steps, call actions and pass data between them.

It would be perfectly possible to put all of the orchestration there.

But then every new reaction becomes another piece of workflow YAML.

That has consequences I do not particularly like:

  • the integration logic is coupled to GitHub Actions;
  • testing it locally becomes awkward;
  • composition happens in YAML rather than code;
  • moving the same behavior to another producer means reproducing the orchestration there.

CI is very good at starting things.

I am less convinced it should become the runtime for the application behavior it starts.

I wanted the workflow to produce the event and hand it to something else.

I didn’t want the blog repository to become an integration framework either

Moving the reactions into TypeScript solves part of the problem.

A local listener can be easy to write, easy to test and easy to invoke.

But then the next question appears: where should reusable behavior live?

If I write a generic listener that posts to a webhook, why should that implementation belong to this blog repository?

If another repository wants the same behavior, copying it there would give me two implementations to maintain.

This is where I started thinking about GitHub Actions again, but not literally GitHub Actions.

What I wanted from that model was the composability:

  • a small executable unit;
  • independently versioned;
  • configured by the consumer;
  • imported from somewhere else;
  • reusable across producers.

I did not need the CI platform itself to provide the runtime.

With Deno, a TypeScript configuration file can import those building blocks directly. Local listeners can still exist, while reusable listeners and conditions can live in packages elsewhere.

At that point the blog no longer needed to own the integrations either.

In retrospect, this was a small exercise in architectural walking. Every time I came back to the problem, I had learned something new and changed one assumption: the reaction did not have to be Twitter, the producer did not have to be Jekyll, and execution did not have to belong to GitHub Actions. Each pass exposed another piece of coupling that could move somewhere more appropriate.

A runtime starts to appear

After removing event detection from the reactions, orchestration from GitHub Actions and reusable integrations from the blog, there was a surprisingly small responsibility left in the middle.

Something had to:

  1. receive an event;
  2. evaluate which routes match it;
  3. invoke the configured listeners;
  4. report what happened.

That is the runtime.

The architecture had become roughly this:

Producer
   |
   v
 Event
   |
   v
Routes / conditions
   |
   v
Listeners
   |
   v
Report

The important part is what each boundary knows.

The producer knows how to discover an event. For this blog, that means Jekyll-specific publication logic.

The runtime knows how to route and execute an event. It does not need to know where the event came from.

A condition decides whether a route applies.

A listener reacts to a matching event.

Once those responsibilities were explicit, another thing became obvious.

Jekyll disappeared

The runtime did not need to know about Jekyll at all.

It did not need to inspect front matter.

It did not need Git history.

It did not need to understand publication dates, GitHub Pages or how this site determines that a post is live.

Those concerns all belong at the producer edge.

The runtime only needs an event and some contracts for routing it.

The current Hooksmith event model describes things such as the event type, timestamp, source, subject, metadata and data. A producer serializes one event as YAML or JSON and hands it to the runtime.

That producer could be a static-site pipeline, but it could just as easily be a release workflow or a deployment system.

This was the point where the original problem stopped defining the solution.

A useful abstraction had appeared by removing assumptions rather than adding capabilities.

Why not stop at a callback?

There is another reasonable question here.

Why not simply expose something like this?

onPublished(post => announce(post));

For the original problem, that would be enough.

But once listeners can be independently reusable and configuration can select what runs, a few requirements appear naturally.

I wanted multiple routes to be able to match the same event. I wanted conditions to be composable. I wanted listeners to execute in a predictable order. I wanted one listener failure not to prevent later listeners from running. I wanted a fallback when nothing matched.

And once execution has side effects, I wanted to be able to ask what would run without actually running it.

That became run --plan.

None of those requirements came from a desire to build an event framework.

They came from taking the small pieces seriously once they had been separated.

What Hooksmith 0.2.0 looks like

The resulting configuration is deliberately ordinary TypeScript.

For example, the standard package can provide reusable conditions while the configuration decides how they are composed:

import type { Config } from "@hooksmith/core";
import {
  all,
  eventType,
  sourceKind,
} from "@hooksmith/standard";

export default {
  routes: [
    {
      name: "published-pages",
      when: all(
        eventType("page.published"),
        sourceKind("website"),
      ),
      listeners: [
        // reactions
      ],
    },
  ],
} satisfies Config;

By version 0.2.0, Hooksmith had split into four packages with deliberately different responsibilities:

@hooksmith/core       public contracts
@hooksmith/runtime    routing and execution
@hooksmith/cli        process boundary
@hooksmith/standard   reusable conditions and listeners

The dependency direction is important too:

core <- runtime <- cli
  ^
  |
standard

An extension package depends on the public contracts, not on the runtime implementation.

That is a small detail in the repository structure, but it is also the same architectural decision that started the whole exercise: a component should depend on the responsibility it actually needs, not on everything that happens to exist around it.

Architecture by subtraction

Looking back, the path from “tweet my new blog post” to Hooksmith was mostly subtraction.

I removed Twitter from the publication detector.

I removed the reactions from GitHub Actions.

I removed reusable listeners from the blog repository.

I removed Jekyll from the runtime.

I removed the runtime implementation from the extension contracts.

The progression looked something like this:

Tweet a Jekyll post
        |
        v
React to a published page
        |
        v
React to an event
        |
        v
Route an event to listeners

Each step knew less about the original use case.

That is the part I find architecturally interesting.

Generalization is often presented as adding an abstraction on top of concrete code. In this case, the useful abstraction emerged by asking which assumptions could move to the edges until the middle had almost no domain knowledge left.

You can even read those questions as tiny stressors against the design: what if the reaction is not Twitter? What if the producer is not Jekyll? What if GitHub Actions is not where the event is executed? None of them was a prediction about the future. They were ways to disturb an assumption and see what parts of the design still made sense.

Was this overengineering?

If the requirement had remained “send one tweet when one Jekyll post is published”, then yes.

A script would have won easily.

The mistake would have been starting the day by deciding that a generic event runtime was obviously required for my blog.

That is not what happened.

The runtime only became defensible as the responsibilities separated:

  • publication detection was producer-specific;
  • reactions multiplied;
  • reusable behavior did not belong to the producer;
  • orchestration did not need to belong to CI;
  • extensions only needed stable contracts;
  • execution needed predictable routing and failure semantics.

At each step I could have stopped.

Apparently I did not.

I started with:

Jekyll -> Twitter

I ended with:

producer -> event -> routes -> listeners

Jekyll and Twitter both moved to the edges.

And that is apparently how you spend a day trying to automate a tweet and come back with version 0.2.0 of an event runtime.

Support this blog

If you liked this article, consider supporting this blog by buying me a pizza!