Discord Twitter GitHub

A History of Service Weaver's Core API

Sanjay Ghemawat, Michael Whittaker
August 16, 2023

In this blog post, we explore the history of Service Weaver's core components API. The API has steadily evolved since the inception of Service Weaver over two years ago.

We hope that by studying the API's history, you gain a better understanding of

We conclude with some lessons learned on API design.

September 30, 2021: In the Beginning...

Originally, components were called objects. You defined the API and implementation of an object using an interface and struct, like you do with components today. You also had to define a constructor function that returned an instance of the object. In order for weaver generate to identify these constructor functions, you annotated them with //weaver:object comments, something inspired by //go:generate and //go:build comments. These //weaver:object comments also included the name of the object and some configuration options. Here's an excerpt from a todo app, one of our earliest example applications:

type App interface {
    Add(context.Context, string) (ItemID, error)
    List(context.Context) ([]Item, error)
    Done(context.Context, ItemID) error
}

type impl struct {
    store Store
}

//weaver:object Todo [replicated]
func newTodo(context.Context, weaver.Object) (App, error) {
    return &impl{store: MemStore.Get()}, nil
}

func (*impl) Add(context.Context, string) (ItemID, error) { ... }
func (*impl) List(context.Context) ([]Item, error) { ... }
func (*impl) Done(context.Context, ItemID) error { ... }

The //weaver:object Todo [replicated] comment indicates that newTodo returns an instance of the Todo object, which can be replicated. Notice that the object interface App, object implementation impl, and object Todo all have different names. Not pictured above is a MemStore component with a Store interface. The Todo component gets a handle to the MemStore component by calling MemStore.Get(), a function generated by weaver generate.

Here's main.go.

func main() {
    flag.Parse()
    config := weaver.Config{
        Deployers: []weaver.Deployer{multiprocess.New(), gke.New(gke.Config{})},
    }
    ctx := context.Background()
    weaver.Run(ctx, config, func() {
        srv := newServer(Todo.Get())
        if err := srv.run(); err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
    })
}

To run a Service Weaver app, you called weaver.Run, passing it a weaver.Config and a function to run. A weaver.Config contained information about how the application could be deployed. It was only much later that we formalized this abstraction as a deployer.

November 10, 2021: Generate Object Interfaces

Having to think of three separate names for an object (e.g., App, impl, Todo) was annoying. Plus, writing the object interface was redundant, we thought. So, we removed the need to write object interfaces. Instead an object constructor returned an instance of the object directly (e.g., *impl) and weaver generate generated the appropriate interface from the exported methods defined on the returned implementation. The todo app again:

type impl struct {
    store MemStore
}

//weaver:object Todo [replicated]
func newTodo(context.Context, weaver.Object) (*impl, error) {
    return &impl{store: GetMemStore()}, nil
}

func (*impl) Add(context.Context, string) (ItemID, error) { ... }
func (*impl) List(context.Context) ([]Item, error) { ... }
func (*impl) Done(context.Context, ItemID) error { ... }

Notice that the App interface is gone, and newTodo returns an *impl. weaver generate generates a Todo interface with Add, List, and Done methods. MemStore.Get() was also renamed GetMemStore().

December 14, 2021: Replace weaver.Run With weaver.Init

Having to pass a function to weaver.Run was annoying, so we replaced weaver.Run with weaver.Init which returned a root component. We also removed weaver.Config. Here's main.go:

func main() {
    flag.Parse()
    ctx := context.Background()
    root := weaver.Init(ctx)
    srv := newServer(GetTodo(root))
    if err := srv.run(root); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

July 26, 2022: Introduce weaver.Get[T]

We replaced generated object getters like GetMemStore with a generic weaver.Get[T] function (e.g., weaver.Get[MemStore]). Go 1.18 introduced generics only a couple months prior.

September 6, 2022: Rename Objects to Components

We felt the name "object" was a bit confusing. We considered a bunch of names including "module", "actor", "service", and "grain" before ultimately settling on "component".

September 7, 2022: Don't Generate Component Interfaces

We realized that component interfaces, though a bit redundant, were clear and explicit, so we stopped generating them and again required the programmer to write them. We also realized that the interface name and component name could be the same. Here's the todo app again:

type Todo interface {
    Add(context.Context, string) (ItemID, error)
    List(context.Context) ([]Item, error)
    Done(context.Context, ItemID) error
}

type impl struct {
    store MemStore
}

//weaver:component Todo [replicated]
func newTodo(context.Context, weaver.Component) (Todo, error) {
    return &impl{store: weaver.Get[MemStore]()}, nil
}

func (*impl) Add(context.Context, string) (ItemID, error) { ... }
func (*impl) List(context.Context) ([]Item, error) { ... }
func (*impl) Done(context.Context, ItemID) error { ... }

Additionally, this change allowed you to write and build an application without having to run weaver generate as often.

September 9, 2022: Replace Comments With Constructor Arguments

Annotating constructor functions with //weaver:component comments was very error-prone. If you misspelled the annotation (//waevar:component) or even misformatted the annotation (// weaver:component), weaver generate would silently ignore the comment, leaving you scratching your head why your perfectly valid component wasn't working. This prompted us to remove comment based annotations and replace them with something the Go compiler could validate for us.

As a first step, we removed properties like replicated from comments and replaced them with constructor arguments. Consider again the Todo constructor:

//weaver:component Todo
func newTodo(context.Context, weaver.Component, weaver.Replicated) (Todo, error) {
    return &impl{store: weaver.Get[MemStore]()}, nil
}

//weaver:component Todo is now missing the [replicated] property. Instead, the newTodo function receives a weaver.Replicated argument, signalling that the Todo component can be replicated. Around this time, we also introduced other properties like weaver.Router for routing. Now that properties were first class entities in Go, the Go compiler would ensure you did not misspell or misformat them.

It was also around this time that we removed the weaver.Replicated property entirely. We realized that it was impossible to guarantee that a component was never replicated while still preserving the availability of an application.

September 13, 2022: Replace Comments With weaverMake

We removed comment based annotations entirely! Component constructor functions were now required to begin with weaverMake. We also starting using the name of the interface as the name of the component. Here's Todo again:

func weaverMakeTodo(context.Context, weaver.Component) (Todo, error) {
    return &impl{store: weaver.Get[MemStore]()}, nil
}

Requiring a constructor function to begin with weaverMake paralleled some other naming requirements in Go:

January 11, 2023: Replace Constructors With weaver.Implements[T]

It was still possible to misspell weaverMake and have weaver generate silently ignore a component. We also felt that specifying component properties, like weaver.Router, as constructor arguments was a bit confusing. So, we threw out constructor functions entirely and replaced them with weaver.Implements[T], the API we still use today. Here's the todo app again (though by this point we had deleted the todo app):

type Todo interface {
    Add(context.Context, string) (ItemID, error)
    List(context.Context) ([]Item, error)
    Done(context.Context, ItemID) error
}

type impl struct {
    weaver.Implements[Todo]
    store MemStore
}

func (i *impl) Init(context.Context) error {
    i.store = weaver.Get[MemStore]()
}

func (*impl) Add(context.Context, string) (ItemID, error) { ... }
func (*impl) List(context.Context) ([]Item, error) { ... }
func (*impl) Done(context.Context, ItemID) error { ... }

Note that impl embeds weaver.Implements[Todo] to signal that it implements the Todo component. Instead of calling a constructor function, the Service Weaver runtime instead instantiated and initialized a component by calling the Init method.

We also introduced weaver.WithConfig[T] and weaver.WithRouter[T] to replace constructor arguments:

type impl struct {
    weaver.Implements[Todo]
    weaver.WithConfig[config]
    weaver.WithRouter[router]
    store MemStore
}

March 1, 2023: Open Source Release

We open sourced Service Weaver! 🎉

April 25, 2023: Replace weaver.Init With weaver.Run

Every Service Weaver application had a main component, the one returned by weaver.Init. But unlike every other component, the main component was implicit. The user didn't define the main component; it just... existed. We decided to concretize things by having the programmer explicitly define the main component. To do so, we replaced weaver.Init with weaver.Run, reverting a decision we made over a year prior. Here's the todo app again:

func main() {
    flag.Parse()
    if err := weaver.Run(context.Background(), serve); err != nil {
        log.Fatal(err)
    }
}

type app struct {
    weaver.Implements[weaver.Main]
    todo Todo
}

func (a *app) Init(context.Context) error {
    a.todo = weaver.Get[Todo]()
}

func serve(ctx context.Context, app *app) {
    // ...
}

This API is what we have today. weaver.Run takes in a function that receives an instance of the main component, app in this case. app implements the main component, indicated by its embedded weaver.Implements[weaver.Main]. weaver.Main is the trivial empty interface:

type Main interface {}

April 26, 2023: Replace weaver.Get With weaver.Ref

At this point, any component could call methods on any other component by calling weaver.Get[T]. This had two downsides:

  1. It was confusing. It was hard to understand how messages flowed through your application because any component could call a method on any other component at any time.
  2. It was not secure. If one component was hacked, it had free reign to contact all other components.

To address this, we began an initiative to restrict the set of methods a component could call. An initial idea was to have a programmer enumerate, in a config file, every single component in their app and specify which other components it could communicate with. If component A called weaver.Get[B], the call would fail if component A was not authorized to call methods on component B. This approach had a number of problems. Most notably, it was extremely cumbersome.

Then, we realized we could declare the component call graph directly in the application code itself. To do so, we removed weaver.Get and replaced it with weaver.Ref[T], the API we have today. Here's the todo app yet again:

type impl struct {
    weaver.Implements[Todo]
    store weaver.Ref[MemStore]
}

Rather than calling weaver.Get[MemStore], the impl struct embeds weaver.Ref[MemStore]. When the Service Weaver runtime constructs an impl, it initializes the weaver.Ref[MemStore] with a reference to the MemStore component.

With this approach, every Service Weaver application implicitly defines a static component call graph. This makes it easier to understand which components communicate with each other. We even have a tool, weaver callgraph, that extracts and visualizes this call graph. Moreover, at runtime, we use mTLS to ensure that components only communicate with the components to which they have references.

May 30, 2023: Introduce Main Method

We started to think that the signature of weaver.Run was a bit too complicated. It was difficult to understand unless you were very familiar with Go generics:

type PointerToMain[T any] interface {
    *T
    InstanceOf[Main]
}
func Run[T any, P PointerToMain[T]](ctx context.Context, app func(context.Context, *T) error) error

So, we added a Main method to the main component:

type Main interface {
    Main(context.Context) error
}

And, we simplified weaver.Run to simply call this Main method. As a result, the signature of weaver.Run was much simpler:

func Run(context.Context)

Here's the todo app again:

func main() {
    flag.Parse()
    if err := weaver.Run(context.Background()); err != nil {
        log.Fatal(err)
    }
}

type app struct {
    weaver.Implements[weaver.Main]
    todo weaver.Ref[Todo]
}

func (a *app) Main(ctx context.Context) {
    // ...
}

June 21, 2023: Remove Main Method

Service Weaver ships with a weavertest package that you can use to test a Service Weaver application. Here's how to test the Todo component, for example:

func TestTodo(t *testing.T) {
    weavertest.Local.Test(t, func(t *testing.T, todo Todo) {
        // Test Todo here...
    })
}

weavertest.Local.Test is similar to weaver.Run. It runs a local copy of the Service Weaver application, initializing all components as necessary. It then calls the provided function with a handle to the Todo component.

We felt it was unclear whether the Test function should run the Main method of the main component or not. Because it mirrors weaver.Run, there's an argument that it should. However, many tests don't want to or can't run the Main method. For example, if a Main method listens on a hardcoded address (e.g., localhost:9000), then two calls to Test cannot run in parallel if they both call the Main method. Ultimately, we decided to revert back to the more explicit weaver.Run:

func main() {
    flag.Parse()
    if err := weaver.Run(context.Background(), serve); err != nil {
        log.Fatal(err)
    }
}

type app struct {
    weaver.Implements[weaver.Main]
    todo weaver.Ref[Todo]
}

func serve(ctx context.Context, app *app) {
    // ...
}

This version of weaver.Run clearly runs the provided function (e.g., serve), and weavertest.Local.Test clearly does not.

Lessons Learned

This brings us to the Service Weaver API we have today. The most important lesson here is that good interface design is hard. We went back and forth on how to declare components. One thing that can often help is to model things after existing APIs. E.g., it is easy to define an API for a familiar concept like an associative container. However the problem we faced, embedding remote communication interfaces in the middle of Go, was unfamiliar and therefore we made several missteps. What really helped was taking the time to get experience with what we were building and improving it as we identified rough spots.

Another important realization was the tension between asking users to be precise and reducing boilerplate. E.g., we switched from automatically generating component interfaces to asking users to supply the interfaces. This increased the burden on programmers, but was better for readers - they now get a nicely commented and formatted interface definition to program against and it is clear what to do when making changes to such an interface.

New mini-languages (like Go comment directives) are easy to create, but can get out of hand easily as they are extended in an ad-hoc manner. Use existing languages if possible; if you make a new language, be ultra careful with changes to it.

Declaring things ahead of time can be powerful. We started off with cross-component references instantiated via general Go code, but later switched to pre-declaring such connections. This allows us to fetch the communication graph ahead of time. The graph can be useful for documentation, adding security boundaries, better component placement, etc.