How to balance the public APIs of an open-source library β€” practical examples from RailsEventStore

How to balance the public APIs of an open-source library β€” practical examples from RailsEventStore

On twitter, Krzysztof asked an interesting question: what are our thoughts on carving a well-balanced API for an open-source library that RailsEventStore is:

This post is a whirlwind tour on RailsEventStore interfaces, components and decisions behind them.

In case you’re still wondering:

Rails Event Store is a library for publishing, consuming, storing and retrieving events. It’s your best companion for going with an Event-Driven Architecture for your Rails application.

In other words β€” it is a storage for events. And a reader of events. On top of relational database. And a pub-sub mechanism. And a few more bits you could implement by your own, like you would implement a web framework. But nobody implements web frameworks on a daily basis, right?

Client facade

The most straightforward way to start using RES is to instantiate the client:

event_store = RailsEventStore::Client.new

That requires no additional configuration or upfront decisions. Perhaps it’s your first time with this library. You’re evaluating it and exploring its usefulness in context of the project you’re working on. At this point any obstacle from getting to the core functionality is a friction to eliminate. We provide sensible defaults to lean on.

This facade channels most library interactions. It is the entry point. Quite liberal on the inputs in some ways, like accepting both event or collection of events:


# Persists events and notifies subscribed handlers about them
#
# @param events [Array<Event>, Event] event(s)
# @param stream_name [String] name of the stream for persisting events.
# @param expected_version [:any, :auto, :none, Integer] controls optimistic locking strategy. {http://railseventstore.org/docs/expected_version/ Read more}
# @return [self]
def publish(events, stream_name: GLOBAL_STREAM, expected_version: :any)
  enriched_events = enrich_events_metadata(events)
  records         = transform(enriched_events)
  repository.append_to_stream(records, Stream.new(stream_name), ExpectedVersion.new(expected_version))
  enriched_events.zip(records) do |event, record|
    with_metadata(
      correlation_id: event.metadata.fetch(:correlation_id),
      causation_id:   event.event_id,
    ) do
      broker.(event, record)
    end
  end
  self
end

On the other hand, an entry point is the best place to validate the input and catch mistakes early on:

# Subscribes a handler (subscriber) that will be invoked for published events of provided type.
#
# @overload subscribe(subscriber, to:)
#   @param to [Array<Class>] types of events to subscribe
#   @param subscriber [Object, Class] handler
#   @return [Proc] - unsubscribe proc. Call to unsubscribe.
#   @raise [ArgumentError, SubscriberNotExist]
# @overload subscribe(to:, &subscriber)
#   @param to [Array<Class>] types of events to subscribe
#   @param subscriber [Proc] handler
#   @return [Proc] - unsubscribe proc. Call to unsubscribe.
#   @raise [ArgumentError, SubscriberNotExist]
def subscribe(subscriber = nil, to:, &proc)
  raise ArgumentError, "subscriber must be first argument or block, cannot be both" if subscriber && proc
  subscriber ||= proc
  broker.add_subscription(subscriber, to)
end

This further allows collaborators (i.e. broker or repository) to expect and act on a valid input. Also to have much stricter APIs down the stack. Like no longer accepting item-or-array-of-items or string-or-symbol primitives in this RBS description:

module RailsEventStoreActiveRecord
  class EventRepository
    def append_to_stream: (Array[RubyEventStore::Record] records, RubyEventStore::Stream stream, RubyEventStore::ExpectedVersion expected_version) -> untyped
  end
end

Since facade is the thing a developer would interact with the most, its worth taking care of ergonomics. For example, a fluent interface on the reader:

scope = client.read
  .stream('GoldCustomers')
  .backward
  .limit(10)
  .of_type(Customer::GoldStatusGranted)
scope.to_a

Swappable components

Starting with batteries included is great. Over time your project will likely diverge from the defaults “for everyone”. At this point it would be better if the library leaves some room for that.

If RailsEventStore::Client.new was the frontend, here’s the backend view. All of the assumptions, defined as default arguments:

module RailsEventStore
  class Client < RubyEventStore::Client
    attr_reader :request_metadata

    def initialize(mapper: RubyEventStore::Mappers::Default.new,
                   repository: RailsEventStoreActiveRecord::EventRepository.new(serializer: YAML),
                   subscriptions: RubyEventStore::Subscriptions.new,
                   dispatcher: RubyEventStore::ComposedDispatcher.new(
                     RailsEventStore::AfterCommitAsyncDispatcher.new(scheduler: ActiveJobScheduler.new(serializer: YAML)),
                     RubyEventStore::Dispatcher.new),
                   clock: default_clock,
                   correlation_id_generator: default_correlation_id_generator,
                   request_metadata: default_request_metadata)
      super(repository: RubyEventStore::InstrumentedRepository.new(repository, ActiveSupport::Notifications),
            mapper: RubyEventStore::Mappers::InstrumentedMapper.new(mapper, ActiveSupport::Notifications),
            subscriptions: subscriptions,
            clock: clock,
            correlation_id_generator: correlation_id_generator,
            dispatcher: RubyEventStore::InstrumentedDispatcher.new(dispatcher, ActiveSupport::Notifications)
            )
      @request_metadata = request_metadata
    end

    def with_request_metadata(env, &block)
      with_metadata(request_metadata.call(env)) do
        block.call
      end
    end

    private
    def default_request_metadata
      ->(env) do
        request = ActionDispatch::Request.new(env)
        {
          remote_ip:  request.remote_ip,
          request_id: request.uuid
        }
      end
    end
  end
end

You can change any of these components. They’re dependencies. Passed via initializer, not hardcoded.

In need for in-memory event repository for faster tests? Check.

Working on a DynamoDB storage backend? Check.

Not an ActiveRecord fan or integrating with ROM and Hanami? Check.

Same could go for subscriptions β€” i.e. persistent storage over in-memory from evaluating configuration file. Or to replace a broker (the in-process pub-sub bus).

Perhaps you just need the mapper to encrypt the payload, before it is persisted and announced on the message bus.

It is hard to please everyone with a single choice of configuration. But it seems easy to leave some room to diverge. After all each component can have its set of linters, a shared test suite to ensure its playing well with others.

Framework integrations

Little known fact is that RailsEventStore is a tiny wrapper on RubyEventStore. It is more of a configuration preset for RubyEventStore. An umbrella for bringing several gem dependencies together.

But it also comes with several component implementations that are enabled by the presence Rails framework:

You can opt-out from these framework integrations. Chances are you won’t β€” you’ve chosen Rails for a reason. But in case you need it, compose your own event store from the ground up with RubyEventStore.

Think of it this way: RailsEventStore is a specialization of RubyEventStore. Can there be HanamiEventStore, on top RubyEventStore? Sure!

Catch me up on twitter to discuss more β€” my DMs are open.

Now, a plug πŸ”Œ. Join ARKADEMY.DEV and get access to our best courses: Rails Architect Masterclass, Anti-IF course, Blogging for busy programmers, Async Remote, TDD video class, Domain-Driven Rails video course and growing!

You might also like