tinka alpha

Emitter

This guide covers events in Tinka. You will learn how to:

Overview

Events let one part of an App announce that something happened, and other parts react, without knowing about each other. After a user signs up, the App sends a welcome email, adds the user to the CRM and records the sign-up for analytics. The sign-up code shouldn't know about any of that: it emits a UserRegistered event, and a listener for each concern reacts.

The events package (github.com/mucolabs/tinka/events) delivers events. An event is any Go type, usually a struct holding what listeners need. A listener is a function that receives it. Because the event's type is its name, the compiler checks that every emit and every listener agree on what the event holds.

Create an emitter

Create one Emitter for the App, as a singleton in its container, so the App waits for background listeners when it shuts down:

main.go
	app.Container().Singleton(func(*di.Resolver) (*events.Emitter, error) {
		return events.New(), nil
	})

Define and emit events

Define each event as a type. Emit it where it happens, passing the request's context:

accounts.go
package main

import (
	"net/http"

	"github.com/mucolabs/tinka"
	"github.com/mucolabs/tinka/events"
)

type UserRegistered struct {
	User User
}

type Accounts struct {
	Events *events.Emitter
	Users  *UserStore
}

func (a Accounts) SignUp(c *tinka.Ctx) (User, error) {
	in, err := c.Body[SignUpForm]()
	if err != nil {
		return User{}, err
	}
	user, err := a.Users.Create(c, in.Email)
	if err != nil {
		return User{}, err
	}
	if err := a.Events.Emit(c, UserRegistered{User: user}); err != nil {
		return User{}, err
	}
	c.Status(http.StatusCreated)
	return user, nil
}

Add listeners

On adds a listener. Its event parameter's type says which events it receives. Register listeners while the App starts, next to your routes:

listeners.go
func listen(bus *events.Emitter, mailer *Mailer, crm *CRM) {
	bus.On(func(ctx context.Context, e UserRegistered) error {
		return mailer.SendWelcome(ctx, e.User)
	})
	bus.On(func(ctx context.Context, e UserRegistered) error {
		return crm.AddContact(ctx, e.User.Email)
	})
}

A listener receives events of its exact type only: a type that embeds UserRegistered is another event. Once adds a listener for the next event only, and OnAny adds one for every event, such as one that logs them:

go
	bus.OnAny(func(ctx context.Context, e any) error {
		logger.InfoContext(ctx, "event", "type", fmt.Sprintf("%T", e))
		return nil
	})

On and OnAny return a function that removes the listener. Clear[T] removes every listener of one event type, and ClearAll removes them all.

Wait, run in order, or run in the background

Emit runs every listener at once and waits for all of them, so the handler continues once the reactions are done. It returns their errors together.

EmitSerial runs listeners one at a time, in the order they were added, and stops at the first error: use it when a listener depends on the one before.

EmitAsync returns at once and runs the listeners in the background, for work the user shouldn't wait for, such as sending an email:

accounts.go
	if err := a.Events.Emit(c, UserRegistered{User: user}); err != nil {
		return User{}, err
	}
	a.Events.EmitAsync(c, UserRegistered{User: user})

Background listeners keep the request's context values, such as its ID for the logs, but not its cancellation, so they finish after the response is sent. When the App shuts down, it waits for them, within its shutdown timeout.

Handle errors

A listener that returns an error or panics doesn't stop the others: a panic becomes an error instead of crashing the App. Emit and EmitSerial return the errors. Errors from EmitAsync have no caller to return to, so they're logged.

OnError sends every listener's error to one function instead, such as one that reports to your error tracker. With it set, Emit and EmitSerial return nil:

listeners.go
	bus.OnError(func(ctx context.Context, event any, err error) {
		logger.ErrorContext(ctx, "event listener failed", "event", fmt.Sprintf("%T", event), "err", err)
	})

Fake events in tests

To test code that emits events without running their listeners, such as ones that send real email, call Fake. Events are recorded instead of delivered, and the returned Recorder checks them. Restore the emitter when the test ends:

accounts_test.go
func TestSignUp(t *testing.T) {
	bus := events.New()
	rec := bus.Fake()
	t.Cleanup(bus.Restore)

	accounts := Accounts{Events: bus, Users: newUserStore(t)}
	signUp(t, accounts, "ada@example.com")

	rec.AssertEmitted(t, func(e UserRegistered) bool {
		return e.User.Email == "ada@example.com"
	})
	rec.AssertNotEmitted[OrderShipped](t)
}

Pass event types to fake some only: bus.Fake(events.TypeOf[UserRegistered]()) records sign-ups while other events reach their listeners.

AssertEmitted[T]func(t TB, match ...func(T) bool)

Fails unless an event of type T, matching match when given, was emitted.

AssertNotEmitted[T]func(t TB, match ...func(T) bool)

Fails if an event of type T, matching match when given, was emitted.

AssertEmittedCount[T]func(t TB, n int)

Fails unless exactly n events of type T were emitted.

AssertNoneEmittedfunc(t TB)

Fails if any event was emitted.

Emitted[T], All, Len, FlushRecorder methods

Return the recorded events of type T, all of them, or how many; Flush forgets them.

See also: Application lifecycle, Dependency injection, Logger