Create a Behavior

This guide walks through creating a custom behavior that attaches domain logic to a resource type. We will build a behavior for a “blog-post” type that auto-generates a URL slug from the title.

Prerequisites

  • A working WeOS development environment (make build succeeds)
  • A preset that defines the resource type you want to add behavior to (see Creating a Preset)

Note: This guide uses the module import path github.com/wepala/weos/v3/..., which matches the go.mod declaration (module github.com/wepala/weos/v3).

1. Create the Behavior Struct

In your preset package (e.g. application/presets/blog/), create a struct that embeds entities.DefaultBehavior and overrides the hooks you need. All imports use the module path from go.mod (currently weos/v3):

package blog

import (
    "context"
    "encoding/json"
    "fmt"
    "strings"

    "github.com/wepala/weos/v3/domain/entities"
)

type blogPostBehavior struct {
    entities.DefaultBehavior
}

Embedding DefaultBehavior means every hook you do not override is a no-op pass-through.

2. Implement the Hooks

Override only the hooks your behavior requires. For auto-generating a URL slug:

func (b *blogPostBehavior) BeforeCreate(
    ctx context.Context, data json.RawMessage, rt *entities.ResourceType,
) (json.RawMessage, error) {
    return injectSlug(data)
}

func (b *blogPostBehavior) BeforeUpdate(
    ctx context.Context, _ *entities.Resource, data json.RawMessage, rt *entities.ResourceType,
) (json.RawMessage, error) {
    return injectSlug(data)
}

func injectSlug(data json.RawMessage) (json.RawMessage, error) {
    var m map[string]any
    if err := json.Unmarshal(data, &m); err != nil {
        return nil, fmt.Errorf("invalid blog post data: %w", err)
    }
    if title, ok := m["title"].(string); ok && m["urlSlug"] == nil {
        m["urlSlug"] = strings.ToLower(strings.ReplaceAll(title, " ", "-"))
    }
    return json.Marshal(m)
}

Hook Selection Guide

When you need to… Use this hook
Transform or enrich input data BeforeCreate / BeforeUpdate
Validate business rules before commit BeforeCreateCommit / BeforeUpdateCommit
Prevent deletion based on a condition BeforeDelete
Trigger side effects after persistence AfterCreate / AfterUpdate / AfterDelete

3. Register the Behavior in the Preset

In your preset’s Register function, include the behavior in the Behaviors map keyed by the resource type slug:

package blog

import (
    "github.com/wepala/weos/v3/application"
    "github.com/wepala/weos/v3/domain/entities"
)

func Register(registry *application.PresetRegistry) {
    registry.MustAdd(application.PresetDefinition{
        Name:        "blog",
        Description: "Blog content types",
        Types: []application.PresetResourceType{
            application.NewPresetType("BlogPost", "blog-post",
                "A blog post",
                `{"@vocab": "https://schema.org/"}`,
                `{
                    "type": "object",
                    "properties": {
                        "title":   {"type": "string"},
                        "urlSlug": {"type": "string"},
                        "body":    {"type": "string"}
                    },
                    "required": ["title"]
                }`,
            ),
        },
        Behaviors: map[string]application.BehaviorFactory{
            "blog-post": application.StaticBehavior(&blogPostBehavior{}),
        },
    })
}

The slug key ("blog-post") must match the resource type’s slug exactly.

Behaviors is a map of factory functions, not pre-built instances. Factories are invoked at startup, after the dependency injection container is wired, so a behavior can close over real repositories and loggers. For a behavior with no service dependencies (like the one above), application.StaticBehavior wraps a plain instance.

3a. Inject Application Services

If your behavior needs a repository or logger, write a real factory instead of StaticBehavior. The factory receives a populated application.BehaviorServices:

Field Type Use for
Resources repositories.ResourceRepository reading other resources (queries, lookups)
Triples repositories.TripleRepository reading relationships between resources
ResourceTypes repositories.ResourceTypeRepository loading a type’s schema/context (e.g. to call application.EdgeValue)
Logger entities.Logger structured logging
Writer application.ResourceWriter creating, updating, or deleting other resources through the full service pipeline

Example (showing imports):

import (
    "github.com/wepala/weos/v3/application"
    "github.com/wepala/weos/v3/domain/entities"
    "github.com/wepala/weos/v3/domain/repositories"
)

type blogPostBehavior struct {
    entities.DefaultBehavior
    resources repositories.ResourceRepository
    logger    entities.Logger
}

func newBlogPostBehavior(s application.BehaviorServices) entities.ResourceBehavior {
    return &blogPostBehavior{resources: s.Resources, logger: s.Logger}
}

// In Register():
Behaviors: map[string]application.BehaviorFactory{
    "blog-post": newBlogPostBehavior,
},

The factory is called once, at startup, by application.ProvideResourceBehaviorRegistry.

Creating or updating other resources

When a hook needs to write other resources (not just transform the current one), use services.Writer. It is a ResourceWriter that forwards to the real ResourceService, so writes go through schema validation, JSON-LD graph assembly, triple extraction, event recording, UnitOfWork commit, and nested behavior dispatch — i.e. creating a resource from a behavior runs that resource’s own behaviors in turn.

Example: a comment behavior that bumps a replyCount on the parent post:

type commentBehavior struct {
    entities.DefaultBehavior
    writer    application.ResourceWriter
    resources repositories.ResourceRepository
    types     repositories.ResourceTypeRepository
    logger    entities.Logger
}

func newCommentBehavior(s application.BehaviorServices) entities.ResourceBehavior {
    return &commentBehavior{
        writer:    s.Writer,
        resources: s.Resources,
        types:     s.ResourceTypes,
        logger:    s.Logger,
    }
}

func (b *commentBehavior) AfterCreate(ctx context.Context, r *entities.Resource) error {
    commentType, err := b.types.FindBySlug(ctx, r.TypeSlug())
    if err != nil {
        return err
    }
    postID := application.EdgeValue(r.Data(), commentType.Context(), "postId")
    if postID == "" {
        return nil
    }
    post, err := b.resources.FindByID(ctx, postID)
    if err != nil {
        return err
    }
    // Build newData by round-tripping post.Data() through json.Unmarshal /
    // json.Marshal and incrementing the replyCount field. Preserve `@id` and
    // `@context` on the result — Update revalidates the full JSON-LD graph
    // and will reject a payload that has lost them.
    _, err = b.writer.Update(ctx, application.UpdateResourceCommand{
        ID:   post.GetID(),
        Data: newData,
    })
    return err
}

Behavior cascades are bounded: ResourceService caps the nesting depth (see maxBehaviorRecursionDepth in application/resource_service.go) to catch accidental cycles. If you hit that limit, you probably have a loop — check that two behaviors aren’t triggering each other indefinitely.

4. Register the Preset

Add your preset to the RegisterAll function in application/presets/register.go, which is where all built-in presets are registered. This function is called by presets.NewDefaultRegistry(), which is used by the CLI (internal/cli/di.go, internal/cli/serve.go) and the MCP server:

import "github.com/wepala/weos/v3/application/presets/blog"

func RegisterAll(registry *application.PresetRegistry) {
    core.Register(registry)
    // ...existing presets...
    blog.Register(registry)  // add your preset here
}

5. Test the Behavior

Write a unit test that calls the behavior hooks directly:

func TestBlogPostBehavior_InjectsSlug(t *testing.T) {
    b := &blogPostBehavior{}
    input := json.RawMessage(`{"title": "My First Post"}`)

    output, err := b.BeforeCreate(context.Background(), input, nil)
    require.NoError(t, err)

    var m map[string]any
    require.NoError(t, json.Unmarshal(output, &m))
    assert.Equal(t, "my-first-post", m["urlSlug"])
}

Run with:

go test -v -run TestBlogPostBehavior ./application/presets/blog/

Real-World Example

The core preset’s personBehavior (application/presets/core/preset.go) computes a full name field by concatenating givenName and familyName. It overrides BeforeCreate and BeforeUpdate — the same pattern shown above.

Further Reading


Back to top

WeOS is open source under AGPL-3.0. Built by Wepala.

This site uses Just the Docs, a documentation theme for Jekyll.