implementation reference

Masstransit Saga Implementation

Guides implementation of MassTransit state machine sagas with MagicOnion gRPC interfaces, Marten persistence, and Basis.Testing integration tests. Covers union contracts, saga state classes, state machines, activities, manager services, and test infrastructure.

MassTransit Saga Implementation Guide

This skill teaches how to implement workflow orchestration using MassTransit state machines integrated with MagicOnion gRPC services, Marten document persistence, and RabbitMQ messaging.

When to Use This Skill

Use this skill when implementing:

  • Multi-step business workflows requiring state tracking
  • Saga/orchestration patterns that span multiple operations
  • Manager services that coordinate calls to accessors
  • Long-running processes with events and state transitions

Architecture Overview

┌─────────────────┐     publishes      ┌────────────────────────┐
│  Manager        │ ───────────────────▶│  MassTransit Bus       │
│  (MagicOnion)   │     events         │  (RabbitMQ)            │
└─────────────────┘                     └───────────┬────────────┘
                                                    │
                                                    ▼
                                        ┌────────────────────────┐
                                        │  State Machine         │
                                        │  (Saga)                │
                                        └───────────┬────────────┘
                                                    │
                                    ┌───────────────┴───────────────┐
                                    │                               │
                                    ▼                               ▼
                         ┌──────────────────┐            ┌──────────────────┐
                         │  Saga State      │            │  Activities      │
                         │  (Marten)        │            │  (Accessor calls)│
                         └──────────────────┘            └──────────────────┘
## Project Structure

src/services/manager/<name>/ ├── Lista.Manager.<Name>.Interfaces/ │ └── v1/Company/ # Use Company (not Employer) per naming conventions │ └── IAdminManager.cs # gRPC interface with Union types ├── Lista.Manager.<Name>.Interfaces.Messaging/ │ └── Events.cs # MassTransit event contracts ├── Lista.Manager.<Name>.Service/ │ ├── Sagas/ │ │ ├── <Workflow>SagaState.cs # Saga state document │ │ ├── <Workflow>StateMachine.cs # MassTransit state machine │ │ └── Activities/ │ │ └── <Action>Activity.cs # External service calls │ └── v1/Company/ │ └── <Name>Manager.cs # Service implementation └── Lista.Manager.<Name>.Tests/ └── <Workflow>SagaTests.cs # Integration tests


## Implementation Blueprint

### Phase 1: Interface & Contract Definitions

#### 1.1 Create gRPC Interface with Unions

**File:** `*.Interfaces/v1/Company/IAdminManager.cs`

```csharp
using Basis.Core.Mediation;
using MagicOnion;
using MessagePack;
using System.ComponentModel.DataAnnotations;
using Key = MessagePack.KeyAttribute;

namespace Lista.Manager.<Name>.Interfaces.v1.Company;

/// <summary>
/// Manager service for company workflow operations.
/// </summary>
public interface IAdminManager : IService<IAdminManager>
{
    /// <summary>
    /// Unified entry point for all workflow operations.
    /// The request type determines which workflow step to execute.
    /// </summary>
    UnaryResult<IWorkflowStepResponse> Execute(IWorkflowStepRequest request);
}

#region Request Union

/// <summary>
/// Polymorphic union for all workflow step requests.
/// New steps added as additional union types without changing interface.
/// </summary>
[Union(0, typeof(StartWorkflowRequest))]
[Union(1, typeof(SubmitFormRequest))]
public interface IWorkflowStepRequest
{
}

#endregion

#region Response Union

/// <summary>
/// Polymorphic union for all workflow step responses.
/// </summary>
[Union(0, typeof(StartWorkflowResponse))]
[Union(1, typeof(SubmitFormResponse))]
[Union(2, typeof(ManagerError))]
public interface IWorkflowStepResponse
{
}

#endregion

#region Start Workflow

[MessagePackObject]
public class StartWorkflowRequest : IWorkflowStepRequest, IRequest<IWorkflowStepResponse>
{
    [Key(0)]
    [Required(ErrorMessage = "CompanyId is required")]
    public string CompanyId { get; set; } = string.Empty;
}

[MessagePackObject]
public class StartWorkflowResponse : IWorkflowStepResponse
{
    [Key(0)]
    public Guid CorrelationId { get; set; }

    [Key(1)]
    public string CurrentState { get; set; } = string.Empty;

    [Key(2)]
    public string NextFormType { get; set; } = string.Empty;
}

#endregion

#region Submit Form

[MessagePackObject]
public class SubmitFormRequest : IWorkflowStepRequest, IRequest<IWorkflowStepResponse>
{
    [Key(0)]
    [Required(ErrorMessage = "CorrelationId is required")]
    public Guid CorrelationId { get; set; }

    [Key(1)]
    [Required(ErrorMessage = "CompanyId is required")]
    public string CompanyId { get; set; } = string.Empty;

    // Add additional form fields with [Key(n)] attributes
    // All fields MUST have sequential Key indices
}

[MessagePackObject]
public class SubmitFormResponse : IWorkflowStepResponse
{
    [Key(0)]
    public bool Success { get; set; }

    [Key(1)]
    public string CurrentState { get; set; } = string.Empty;

    [Key(2)]
    public Guid CorrelationId { get; set; }
}

#endregion

#region Error Types

[MessagePackObject]
public class ManagerError : IWorkflowStepResponse
{
    [Key(0)]
    public ManagerErrorCode Code { get; set; }

    [Key(1)]
    public string Message { get; set; } = string.Empty;

    [Key(2)]
    public string? Field { get; set; }
}

public enum ManagerErrorCode
{
    NotFound = 0,
    Conflict = 1,
    ValidationFailed = 2,
    Timeout = 3,
    InternalError = 4
}

#endregion

Key Points:

  • Use [Union(n, typeof(...))] with sequential indices starting at 0
  • All MessagePack types need [MessagePackObject] and [Key(n)] on every property
  • Use Key = MessagePack.KeyAttribute to avoid conflicts with DataAnnotations
  • Implement IRequest<TResponse> for Basis.Core.Mediation support
  • Add validation attributes ([Required], [RegularExpression], etc.)

1.2 Create MassTransit Event Contracts

File: *.Interfaces.Messaging/Events.cs

using MessagePack;

namespace Lista.Manager.<Name>.Interfaces.Messaging;

/// <summary>
/// Published when workflow is initiated. Starts the saga.
/// </summary>
[MessagePackObject]
public class WorkflowStarted
{
    [MessagePack.Key(0)]
    public Guid CorrelationId { get; set; }

    [MessagePack.Key(1)]
    public string CompanyId { get; set; } = string.Empty;

    [MessagePack.Key(2)]
    public DateTimeOffset Timestamp { get; set; }
}

/// <summary>
/// Published when client submits form. Continues saga.
/// </summary>
[MessagePackObject]
public class PayrollProfileSubmitted
{
    [MessagePack.Key(0)]
    public Guid CorrelationId { get; set; }

    [MessagePack.Key(1)]
    public string CompanyId { get; set; } = string.Empty;

    [MessagePack.Key(2)]
    public string FederalEmployerId { get; set; } = string.Empty;

    [MessagePack.Key(3)]
    public DateTimeOffset Timestamp { get; set; }
}

/// <summary>
/// Published on successful completion. Other services can subscribe.
/// </summary>
[MessagePackObject]
public class WorkflowCompleted
{
    [MessagePack.Key(0)]
    public Guid CorrelationId { get; set; }

    [MessagePack.Key(1)]
    public string CompanyId { get; set; } = string.Empty;

    [MessagePack.Key(2)]
    public DateTimeOffset Timestamp { get; set; }
}

/// <summary>
/// Scheduled timeout message.
/// </summary>
[MessagePackObject]
public class WorkflowTimeout
{
    [MessagePack.Key(0)]
    public Guid CorrelationId { get; set; }
}

Key Points:

  • All events include CorrelationId for saga routing
  • Include Timestamp for audit trails
  • Use [MessagePack.Key(n)] (fully qualified) to avoid ambiguity

Phase 2: State Machine Implementation

2.1 Create Saga State Class

File: *.Service/Sagas/<Workflow>SagaState.cs

using MassTransit;
using Marten.Schema;

namespace Lista.Manager.<Name>.Service.Sagas;

/// <summary>
/// Saga state persisted to Marten/PostgreSQL as JSON documents.
/// </summary>
public class WorkflowSagaState : SagaStateMachineInstance
{
    /// <summary>
    /// Unique correlation ID for this saga instance.
    /// Used as document ID in Marten.
    /// </summary>
    [Identity]
    public Guid CorrelationId { get; set; }

    /// <summary>
    /// Marten document Id - maps to CorrelationId for document storage.
    /// </summary>
    public Guid Id
    {
        get => CorrelationId;
        set => CorrelationId = value;
    }

    /// <summary>
    /// Current state of the saga (stored as string).
    /// </summary>
    public string CurrentState { get; set; } = string.Empty;

    #region Business Data

    public string CompanyId { get; set; } = string.Empty;
    
    // Add business fields captured during workflow
    public string? LegalName { get; set; }
    public string? FederalEmployerId { get; set; }

    #endregion

    #region Audit Fields

    public DateTimeOffset CreatedAt { get; set; }
    public DateTimeOffset UpdatedAt { get; set; }
    public DateTimeOffset? CompletedAt { get; set; }
    public string? ErrorMessage { get; set; }

    #endregion
}

Key Points:

  • Implement SagaStateMachineInstance (MassTransit requirement)
  • Use [Identity] attribute on CorrelationId for Marten
  • Add Id property that maps to CorrelationId
  • CurrentState MUST be string type for Marten persistence
  • Include audit fields for troubleshooting

2.2 Create State Machine

File: *.Service/Sagas/<Workflow>StateMachine.cs

using Lista.Manager.<Name>.Interfaces.Messaging;
using Lista.Manager.<Name>.Service.Sagas.Activities;
using MassTransit;

namespace Lista.Manager.<Name>.Service.Sagas;

/// <summary>
/// State machine orchestrating the workflow.
/// States: Initial -> AwaitingForm -> Completed (or Failed)
/// </summary>
public class WorkflowStateMachine : MassTransitStateMachine<WorkflowSagaState>
{
    public WorkflowStateMachine()
    {
        // CRITICAL: Configure state storage as string
        InstanceState(x => x.CurrentState);

        // Define event correlation
        Event(() => WorkflowStartedEvent, x => x.CorrelateById(context => context.Message.CorrelationId));
        Event(() => PayrollProfileSubmittedEvent, x => x.CorrelateById(context => context.Message.CorrelationId));

        #region Initial State

        Initially(
            When(WorkflowStartedEvent)
                .Then(context =>
                {
                    context.Saga.CompanyId = context.Message.CompanyId;
                    context.Saga.CreatedAt = context.Message.Timestamp;
                    context.Saga.UpdatedAt = context.Message.Timestamp;
                })
                .TransitionTo(AwaitingForm)
        );

        #endregion

        #region AwaitingForm State

        During(AwaitingForm,
            When(PayrollProfileSubmittedEvent)
                .Then(context =>
                {
                    // Copy form data to saga state
                    context.Saga.FederalEmployerId = context.Message.FederalEmployerId;
                    context.Saga.UpdatedAt = context.Message.Timestamp;
                })
                // Execute activity to call external services
                .Activity(x => x.OfType<StoreProfileActivity>())
                .Then(context =>
                {
                    context.Saga.CompletedAt = DateTimeOffset.UtcNow;
                })
                // Publish completion event
                .Publish(context => new WorkflowCompleted
                {
                    CorrelationId = context.Saga.CorrelationId,
                    CompanyId = context.Saga.CompanyId,
                    Timestamp = DateTimeOffset.UtcNow
                })
                .TransitionTo(Completed)
                .Finalize()
        );

        #endregion

        // Mark saga for deletion when finalized
        SetCompletedWhenFinalized();
    }

    #region States

    public State AwaitingForm { get; private set; } = null!;
    public State Completed { get; private set; } = null!;
    public State Failed { get; private set; } = null!;

    #endregion

    #region Events

    public Event<WorkflowStarted> WorkflowStartedEvent { get; private set; } = null!;
    public Event<PayrollProfileSubmitted> PayrollProfileSubmittedEvent { get; private set; } = null!;

    #endregion
}

Key Points:

  • InstanceState(x => x.CurrentState) is REQUIRED for string-based state
  • Use .Activity(x => x.OfType<T>()) syntax for activities
  • Call .Finalize() to mark saga for deletion
  • Call SetCompletedWhenFinalized() to enable auto-cleanup

2.3 Create Activity for External Service Calls

File: *.Service/Sagas/Activities/StoreProfileActivity.cs

using Lista.Accessor.Profile.Interfaces.v1.Company;
using Lista.Manager.<Name>.Interfaces.Messaging;
using MassTransit;

namespace Lista.Manager.<Name>.Service.Sagas.Activities;

/// <summary>
/// Activity for calling external services (e.g., Profile Accessor).
/// </summary>
public class StoreProfileActivity : IStateMachineActivity<WorkflowSagaState, PayrollProfileSubmitted>
{
    private readonly IMagicOnionClientFactory _magicOnionClientFactory;

    public StoreProfileActivity(IMagicOnionClientFactory magicOnionClientFactory)
    {
        _magicOnionClientFactory = magicOnionClientFactory;
    }

    public void Probe(ProbeContext context)
    {
        context.CreateScope("store-profile");
    }

    public void Accept(StateMachineVisitor visitor)
    {
        visitor.Visit(this);
    }

    public async Task Execute(
        BehaviorContext<WorkflowSagaState, PayrollProfileSubmitted> context, 
        IBehavior<WorkflowSagaState, PayrollProfileSubmitted> next)
    {
        var message = context.Message;

        var profileAccess = _magicOnionClientFactory.CreateClient<IProfileAccess>();

        // Map to accessor request
        var storeRequest = new PayrollProfileStoreRequest
        {
            Id = message.CompanyId,
            FederalEmployerId = message.FederalEmployerId
        };

        // Call accessor
        var response = await profileAccess.Store(storeRequest);

        // Handle errors
        if (response is AccessorError error)
        {
            context.Saga.ErrorMessage = error.Message;
            throw new InvalidOperationException($"Accessor error: {error.Code} - {error.Message}");
        }

        // Continue to next behavior in chain
        await next.Execute(context);
    }

    public Task Faulted<TException>(
        BehaviorExceptionContext<WorkflowSagaState, PayrollProfileSubmitted, TException> context, 
        IBehavior<WorkflowSagaState, PayrollProfileSubmitted> next) where TException : Exception
    {
        context.Saga.ErrorMessage = context.Exception.Message;
        context.Saga.UpdatedAt = DateTimeOffset.UtcNow;
        return next.Faulted(context);
    }
}

Key Points:

  • Implement IStateMachineActivity<TState, TEvent>
  • Use IMagicOnionClientFactory to create IProfileAccess client(s)
  • Throw exceptions to trigger fault handling in state machine
  • Update saga state in Faulted method

Phase 3: Service Implementation

3.1 Implement Manager Service

File: *.Service/v1/Company/AdminManager.cs

using Lista.Manager.<Name>.Interfaces.Messaging;
using Lista.Manager.<Name>.Interfaces.v1.Company;
using MagicOnion;
using MagicOnion.Server;
using MassTransit;
using DataValidation = System.ComponentModel.DataAnnotations;

namespace Lista.Manager.<Name>.Service.v1.Company;

/// <summary>
/// Manager service that publishes events to drive the state machine.
/// </summary>
public sealed class AdminManager : ServiceBase<IAdminManager>, IAdminManager
{
    private readonly IPublishEndpoint _publishEndpoint;

    public AdminManager(IPublishEndpoint publishEndpoint)
    {
        _publishEndpoint = publishEndpoint;
    }

    public async UnaryResult<IWorkflowStepResponse> Execute(IWorkflowStepRequest request) =>
        request switch
        {
            StartWorkflowRequest r => await HandleStart(r),
            SubmitFormRequest r => await HandleSubmit(r),
            _ => throw new ArgumentException($"Unknown request type: {request.GetType().Name}")
        };

    private async Task<IWorkflowStepResponse> HandleStart(StartWorkflowRequest request)
    {
        // Validate with DataAnnotations
        var validationResults = new List<DataValidation.ValidationResult>();
        if (!DataValidation.Validator.TryValidateObject(
            request, 
            new DataValidation.ValidationContext(request), 
            validationResults, 
            validateAllProperties: true))
        {
            var firstError = validationResults.First();
            return new ManagerError
            {
                Code = ManagerErrorCode.ValidationFailed,
                Message = firstError.ErrorMessage ?? "Validation failed",
                Field = firstError.MemberNames.FirstOrDefault()
            };
        }

        // Normalize inputs
        var normalizedCompanyId = request.CompanyId.Trim().ToUpperInvariant();

        if (string.IsNullOrWhiteSpace(normalizedCompanyId))
        {
            return new ManagerError
            {
                Code = ManagerErrorCode.ValidationFailed,
                Message = "CompanyId is required",
                Field = nameof(request.CompanyId)
            };
        }

        // Generate correlation ID
        var correlationId = Guid.NewGuid();

        // Publish event to start saga
        await _publishEndpoint.Publish(new WorkflowStarted
        {
            CorrelationId = correlationId,
            CompanyId = normalizedCompanyId,
            Timestamp = DateTimeOffset.UtcNow
        });

        return new StartWorkflowResponse
        {
            CorrelationId = correlationId,
            CurrentState = "AwaitingForm",
            NextFormType = "PayrollForm"
        };
    }

    private async Task<IWorkflowStepResponse> HandleSubmit(SubmitFormRequest request)
    {
        // Validate
        var validationResults = new List<DataValidation.ValidationResult>();
        if (!DataValidation.Validator.TryValidateObject(
            request, 
            new DataValidation.ValidationContext(request), 
            validationResults, 
            validateAllProperties: true))
        {
            var firstError = validationResults.First();
            return new ManagerError
            {
                Code = ManagerErrorCode.ValidationFailed,
                Message = firstError.ErrorMessage ?? "Validation failed",
                Field = firstError.MemberNames.FirstOrDefault()
            };
        }

        if (request.CorrelationId == Guid.Empty)
        {
            return new ManagerError
            {
                Code = ManagerErrorCode.ValidationFailed,
                Message = "CorrelationId is required",
                Field = nameof(request.CorrelationId)
            };
        }

        // Normalize and publish
        await _publishEndpoint.Publish(new PayrollProfileSubmitted
        {
            CorrelationId = request.CorrelationId,
            CompanyId = request.CompanyId.Trim().ToUpperInvariant(),
            // Map additional normalized fields
            Timestamp = DateTimeOffset.UtcNow
        });

        return new SubmitFormResponse
        {
            Success = true,
            CurrentState = "Processing",
            CorrelationId = request.CorrelationId
        };
    }
}

Key Points:

  • Use DataValidation = System.ComponentModel.DataAnnotations alias to avoid ValidationResult ambiguity
  • Normalize inputs before publishing events
  • Generate correlation ID on start, pass through on subsequent steps
  • Return immediate response; saga processes asynchronously

Phase 4: Integration Testing

4.1 Create Test Infrastructure

File: *.Tests/<Workflow>SagaTests.cs

using Basis.Core.Mediation;
using Basis.Testing;
using FakeItEasy;
using JasperFx;
using Lista.Accessor.Profile.Interfaces.v1.Company;
using Lista.Common.Testing;
using Lista.Manager.<Name>.Interfaces.v1.Company;
using Lista.Manager.<Name>.Service.Sagas;
using Lista.Manager.<Name>.Service.Sagas.Activities;
using Lista.Manager.<Name>.Service.v1.Company;
using Marten;
using MassTransit;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit.Abstractions;

namespace Lista.Manager.<Name>.Tests;

public class WorkflowSagaTests : IAsyncLifetime
{
    private TestHost? _testHost;
    private readonly ITestOutputHelper _testOutputHelper;

    public WorkflowSagaTests(ITestOutputHelper testOutputHelper)
    {
        _testOutputHelper = testOutputHelper;
    }

    public Task InitializeAsync() => Task.CompletedTask;

    public async Task DisposeAsync()
    {
        if (_testHost != null)
        {
            await _testHost.DisposeAsync();
        }
    }

    #region Test Methods

    [Fact]
    public async Task StartWorkflow_Returns_CorrelationId()
    {
        _testHost = await CreateTestHostWithFakeAccessor();

        var client = _testHost.CreateMagicOnionClient<IAdminManager>();

        var request = new StartWorkflowRequest { CompanyId = "test-company" };
        var response = await client.Execute(request) as StartWorkflowResponse;

        response.ShouldNotBeNull();
        response.CorrelationId.ShouldNotBe(Guid.Empty);
        response.CurrentState.ShouldBe("AwaitingForm");
    }

    [Fact]
    public async Task HappyPath_CompletesSuccessfully()
    {
        _testHost = await CreateTestHostWithFakeAccessor();

        var client = _testHost.CreateMagicOnionClient<IAdminManager>();

        // Start
        var startResponse = await client.Execute(new StartWorkflowRequest 
        { 
            CompanyId = "test-company" 
        }) as StartWorkflowResponse;
        
        startResponse.ShouldNotBeNull();
        
        // Allow saga to transition
        await Task.Delay(1000);

        // Submit
        var submitResponse = await client.Execute(new SubmitFormRequest
        {
            CorrelationId = startResponse.CorrelationId,
            CompanyId = "test-company",
            LegalName = "Test Corp"
        }) as SubmitFormResponse;

        submitResponse.ShouldNotBeNull();
        submitResponse.Success.ShouldBeTrue();

        // Allow saga to complete
        await Task.Delay(2000);
    }

    [Fact]
    public async Task Validation_ReturnsError_ForEmptyCompanyId()
    {
        _testHost = await CreateTestHostWithFakeAccessor();

        var client = _testHost.CreateMagicOnionClient<IAdminManager>();

        var response = await client.Execute(new StartWorkflowRequest 
        { 
            CompanyId = "" 
        }) as ManagerError;

        response.ShouldNotBeNull();
        response.Code.ShouldBe(ManagerErrorCode.ValidationFailed);
        response.Message.ShouldContain("CompanyId");
    }

    #endregion

    #region Test Infrastructure

    private async Task<TestHost> CreateTestHostWithFakeAccessor()
    {
        // Create fake accessor + factory used by activities
        var fakeProfileAccess = A.Fake<IProfileAccess>();
        A.CallTo(() => fakeProfileAccess.Store(A<CompanyAccountingProfileStoreRequest>._))
            .ReturnsLazily(call =>
            {
                var req = call.GetArgument<CompanyAccountingProfileStoreRequest>(0)!;
                return Task.FromResult<IProfileStoreResponse>(
                    new CompanyAccountingProfileStoreResponse
                    {
                        Success = true,
                        Id = req.Id?.Trim().ToUpperInvariant() ?? string.Empty
                    });
            });

        var fakeClientFactory = A.Fake<IMagicOnionClientFactory>();
        A.CallTo(() => fakeClientFactory.CreateClient<IProfileAccess>())
            .Returns(fakeProfileAccess);

        _testHost = await new TestHost()
            .WithTestOutput(_testOutputHelper)
            .WithDockerContainer<PostgresContainer>()
            .WithDockerContainer<MassTransitRabbitMqContainer>()
            .ConfigureServices((services, containers) =>
            {
                services.AddGrpc(options => options.EnableDetailedErrors = true);

                var postgres = containers.Get<PostgresContainer>();
                var rabbit = containers.Get<MassTransitRabbitMqContainer>();

                // Configure Marten with factory pattern
                services.AddMarten(options =>
                {
                    options.Connection(postgres.ConnectionString);
                    options.AutoCreateSchemaObjects = AutoCreate.All;
                });

                // Configure MassTransit with RabbitMQ
                services.AddMassTransit(x =>
                {
                    x.AddSagaStateMachine<WorkflowStateMachine, WorkflowSagaState>()
                        .InMemoryRepository();  // Use InMemory for isolated tests

                    x.UsingRabbitMq((context, cfg) =>
                    {
                        cfg.Host(rabbit.Host, (ushort)rabbit.AmqpPort, "/", h =>
                        {
                            h.Username(rabbit.Username);
                            h.Password(rabbit.Password);
                        });
                        cfg.ConfigureEndpoints(context);
                    });
                });

                // Register fake accessor
                services.AddSingleton(fakeClientFactory);

                // Register activity
                services.AddScoped<StoreProfileActivity>();
            })
            .WithService<IAdminManager, AdminManager>()
            .StartAsync();

        // Wait for MassTransit startup
        await Task.Delay(2000);

        return _testHost;
    }

    #endregion
}

Key Points:

  • Use PostgresContainer and MassTransitRabbitMqContainer from Lista.Common.Testing
  • Use .InMemoryRepository() for isolated tests, .MartenRepository() for full integration
  • Prefer faking IMagicOnionClientFactory to return a fake IProfileAccess in unit/integration tests
  • Add delays (Task.Delay) for async message processing
  • Wait 2000ms after TestHost start for MassTransit initialization

Required Package References

Interfaces Project

<PackageReference Include="Basis.Core" />
<PackageReference Include="MagicOnion.Abstractions" />

Service Project

<PackageReference Include="Basis.Core" />
<PackageReference Include="MagicOnion" />
<PackageReference Include="Marten" />
<PackageReference Include="MassTransit.RabbitMQ" />
<PackageReference Include="Serilog" />

Notes:

  • Do not add MagicOnion.Client to manager/service projects.
  • For ManagerAccessor/Engine calls, inject IMagicOnionClientFactory from Basis.Core and create clients from it.
  • Do not create GrpcChannel/invokers directly; the Basis client factory abstracts that.

Tests Project

<PackageReference Include="Basis.Testing" />
<PackageReference Include="Docker.DotNet" />
<PackageReference Include="FakeItEasy" />
<PackageReference Include="MagicOnion" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />

Common Pitfalls to Avoid

  1. Missing InstanceState() call - State machine won't track state correctly
  2. Wrong [Key] attribute - Use MessagePack.KeyAttribute, not DataAnnotations.KeyAttribute
  3. Non-sequential Key indices - MessagePack requires sequential indices starting at 0
  4. ValidationResult ambiguity - Use DataValidation = System.ComponentModel.DataAnnotations alias
  5. Activity syntax - Use .Activity(x => x.OfType<T>()), not .Activity<T>()
  6. Marten factory pattern - Use AddMarten((sp) => { return options; }) for factory registration
  7. Activity external calls - Fake IMagicOnionClientFactory (returning fake IProfileAccess) for deterministic tests
  8. Timing issues - Add sufficient delays for async saga processing (1000-5000ms)
  9. Missing .Finalize() - Saga won't be cleaned up after completion
  10. Employer vs Company naming - Always use "Company" per naming conventions

Verification Checklist

  • All MessagePack types have [MessagePackObject] and sequential [Key(n)]
  • Union indices are sequential starting at 0
  • State machine has InstanceState(x => x.CurrentState)
  • Events correlate by CorrelationId
  • Activity implements IStateMachineActivity<TState, TEvent>
  • Manager uses DataValidation alias for validation
  • Tests use PostgresContainer and MassTransitRabbitMqContainer
  • Tests wait for MassTransit startup (2000ms delay)
  • Build passes: dotnet build Lista.sln
  • Tests pass: dotnet test Lista.sln

References