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.KeyAttributeto 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
CorrelationIdfor saga routing - Include
Timestampfor 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 onCorrelationIdfor Marten - Add
Idproperty that maps toCorrelationId CurrentStateMUST 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
IMagicOnionClientFactoryto createIProfileAccessclient(s) - Throw exceptions to trigger fault handling in state machine
- Update saga state in
Faultedmethod
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.DataAnnotationsalias to avoidValidationResultambiguity - 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
PostgresContainerandMassTransitRabbitMqContainerfromLista.Common.Testing - Use
.InMemoryRepository()for isolated tests,.MartenRepository()for full integration - Prefer faking
IMagicOnionClientFactoryto return a fakeIProfileAccessin 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.Clientto manager/service projects. - For ManagerAccessor/Engine calls, inject
IMagicOnionClientFactoryfromBasis.Coreand 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
- Missing
InstanceState()call - State machine won't track state correctly - Wrong
[Key]attribute - UseMessagePack.KeyAttribute, notDataAnnotations.KeyAttribute - Non-sequential Key indices - MessagePack requires sequential indices starting at 0
ValidationResultambiguity - UseDataValidation = System.ComponentModel.DataAnnotationsalias- Activity syntax - Use
.Activity(x => x.OfType<T>()), not.Activity<T>() - Marten factory pattern - Use
AddMarten((sp) => { return options; })for factory registration - Activity external calls - Fake
IMagicOnionClientFactory(returning fakeIProfileAccess) for deterministic tests - Timing issues - Add sufficient delays for async saga processing (1000-5000ms)
- Missing
.Finalize()- Saga won't be cleaned up after completion - 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
DataValidationalias for validation - Tests use
PostgresContainerandMassTransitRabbitMqContainer - Tests wait for MassTransit startup (2000ms delay)
- Build passes:
dotnet build Lista.sln - Tests pass:
dotnet test Lista.sln
References
- Profile Accessor Implementation
- Admin Manager Interfaces
- Admin Manager Service
- Payroll Onboarding State Machine
- Saga Tests
- Naming Conventions