Skip to content

Commit b2d61d4

Browse files
committed
feat(core): add typed side effect contracts
1 parent 1d914d3 commit b2d61d4

4 files changed

Lines changed: 368 additions & 11 deletions

File tree

src/Abstractions/Effects/SideEffect.cs

Lines changed: 131 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
using System.Text.Json.Serialization;
2+
13
namespace ModularityKit.Mutator.Abstractions.Effects;
24

35
/// <summary>
46
/// Represents a side effect produced by a mutation.
57
/// Side effects capture additional consequences that are not part of the primary state change.
68
/// </summary>
9+
[JsonConverter(typeof(SideEffectJsonConverter))]
710
public sealed class SideEffect
811
{
912
/// <summary>
@@ -28,6 +31,16 @@ public sealed class SideEffect
2831
/// </summary>
2932
public object? Data { get; init; }
3033

34+
/// <summary>
35+
/// Stable contract identifier for typed side effect payloads.
36+
/// </summary>
37+
public string? DataContractType { get; init; }
38+
39+
/// <summary>
40+
/// Version number for typed side effect payloads.
41+
/// </summary>
42+
public int? DataContractVersion { get; init; }
43+
3144
/// <summary>
3245
/// Timestamp when the side effect occurred.
3346
/// </summary>
@@ -43,7 +56,10 @@ public sealed class SideEffect
4356
/// </summary>
4457
/// <param name="type">The type of the side effect.</param>
4558
/// <param name="description">Human-readable description.</param>
46-
/// <param name="data">Optional associated data.</param>
59+
/// <param name="data">
60+
/// Optional associated data. When the payload type declares <see cref="SideEffectDataContractAttribute"/>,
61+
/// the side effect contract metadata is populated automatically.
62+
/// </param>
4763
/// <param name="severity">Severity level.</param>
4864
/// <param name="requiresAction">
4965
/// Indicates whether the side effect requires explicit follow-up. Critical severity always implies action.
@@ -56,22 +72,47 @@ public static SideEffect Create(
5672
SideEffectSeverity severity = SideEffectSeverity.Info,
5773
bool requiresAction = false,
5874
DateTimeOffset? timestamp = null)
59-
=> new()
60-
{
61-
Type = type,
62-
Description = description,
63-
Data = data,
64-
Severity = severity,
65-
RequiresAction = requiresAction || severity == SideEffectSeverity.Critical,
66-
Timestamp = timestamp ?? DateTimeOffset.UtcNow
67-
};
75+
=> CreateCore(
76+
type,
77+
description,
78+
data,
79+
severity,
80+
requiresAction,
81+
timestamp);
82+
83+
/// <summary>
84+
/// Creates a new <see cref="SideEffect"/> with a typed payload contract.
85+
/// </summary>
86+
/// <typeparam name="TData">The payload type.</typeparam>
87+
/// <param name="type">The type of the side effect.</param>
88+
/// <param name="description">Human-readable description.</param>
89+
/// <param name="data">Typed associated payload.</param>
90+
/// <param name="severity">Severity level.</param>
91+
/// <param name="requiresAction">
92+
/// Indicates whether the side effect requires explicit follow-up. Critical severity always implies action.
93+
/// </param>
94+
/// <param name="timestamp">Optional timestamp override. Defaults to current UTC time.</param>
95+
public static SideEffect Create<TData>(
96+
string type,
97+
string description,
98+
TData data,
99+
SideEffectSeverity severity = SideEffectSeverity.Info,
100+
bool requiresAction = false,
101+
DateTimeOffset? timestamp = null)
102+
{
103+
ArgumentNullException.ThrowIfNull(data);
104+
return CreateCore(type, description, data, severity, requiresAction, timestamp);
105+
}
68106

69107
/// <summary>
70108
/// Creates a new critical <see cref="SideEffect"/> instance.
71109
/// </summary>
72110
/// <param name="type">The type of the side effect.</param>
73111
/// <param name="description">Human-readable description.</param>
74-
/// <param name="data">Optional associated data.</param>
112+
/// <param name="data">
113+
/// Optional associated data. When the payload type declares <see cref="SideEffectDataContractAttribute"/>,
114+
/// the side effect contract metadata is populated automatically.
115+
/// </param>
75116
/// <param name="timestamp">Optional timestamp override. Defaults to current UTC time.</param>
76117
public static SideEffect Critical(
77118
string type,
@@ -85,4 +126,83 @@ public static SideEffect Critical(
85126
SideEffectSeverity.Critical,
86127
requiresAction: true,
87128
timestamp: timestamp);
129+
130+
/// <summary>
131+
/// Creates a new critical <see cref="SideEffect"/> instance with a typed payload contract.
132+
/// </summary>
133+
/// <typeparam name="TData">The payload type.</typeparam>
134+
/// <param name="type">The type of the side effect.</param>
135+
/// <param name="description">Human-readable description.</param>
136+
/// <param name="data">Typed associated payload.</param>
137+
/// <param name="timestamp">Optional timestamp override. Defaults to current UTC time.</param>
138+
public static SideEffect Critical<TData>(
139+
string type,
140+
string description,
141+
TData data,
142+
DateTimeOffset? timestamp = null)
143+
=> Create(
144+
type,
145+
description,
146+
data,
147+
SideEffectSeverity.Critical,
148+
requiresAction: true,
149+
timestamp: timestamp);
150+
151+
/// <summary>
152+
/// Attempts to read the side effect payload as a typed contract.
153+
/// </summary>
154+
/// <typeparam name="TData">The expected payload type.</typeparam>
155+
/// <param name="data">The typed payload when available.</param>
156+
/// <returns><see langword="true"/> when the payload is available as <typeparamref name="TData"/>.</returns>
157+
public bool TryGetData<TData>(out TData? data)
158+
{
159+
if (Data is TData typed)
160+
{
161+
data = typed;
162+
return true;
163+
}
164+
165+
data = default;
166+
return false;
167+
}
168+
169+
private static SideEffect CreateCore(
170+
string type,
171+
string description,
172+
object? data,
173+
SideEffectSeverity severity,
174+
bool requiresAction,
175+
DateTimeOffset? timestamp)
176+
{
177+
var (contractType, contractVersion) = ResolveContract(data);
178+
179+
return new SideEffect
180+
{
181+
Type = type,
182+
Description = description,
183+
Data = data,
184+
Severity = severity,
185+
RequiresAction = requiresAction || severity == SideEffectSeverity.Critical,
186+
Timestamp = timestamp ?? DateTimeOffset.UtcNow,
187+
DataContractType = contractType,
188+
DataContractVersion = contractVersion
189+
};
190+
}
191+
192+
private static (string? ContractType, int? ContractVersion) ResolveContract(object? data)
193+
{
194+
if (data is null)
195+
return (null, null);
196+
197+
var dataType = data.GetType();
198+
var contract = dataType.GetCustomAttributes(typeof(SideEffectDataContractAttribute), inherit: false)
199+
.OfType<SideEffectDataContractAttribute>()
200+
.SingleOrDefault();
201+
202+
if (contract is null)
203+
return (null, null);
204+
205+
SideEffectDataContractRegistry.Register(dataType);
206+
return (contract.ContractType, contract.ContractVersion);
207+
}
88208
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace ModularityKit.Mutator.Abstractions.Effects;
2+
3+
/// <summary>
4+
/// Declares stable contract identifier for typed side effect payloads.
5+
/// </summary>
6+
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)]
7+
public sealed class SideEffectDataContractAttribute(string contractType, int contractVersion = 1) : Attribute
8+
{
9+
/// <summary>
10+
/// Stable contract identifier for the payload.
11+
/// </summary>
12+
public string ContractType { get; } = string.IsNullOrWhiteSpace(contractType)
13+
? throw new ArgumentException("Contract type is required.", nameof(contractType))
14+
: contractType;
15+
16+
/// <summary>
17+
/// Version number for the payload contract.
18+
/// </summary>
19+
public int ContractVersion { get; } = contractVersion > 0
20+
? contractVersion
21+
: throw new ArgumentOutOfRangeException(nameof(contractVersion), "Contract version must be greater than zero.");
22+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System.Collections.Concurrent;
2+
3+
namespace ModularityKit.Mutator.Abstractions.Effects;
4+
5+
/// <summary>
6+
/// Stores registrations that map side effect payload contracts to CLR types.
7+
/// Registered types allow serializers and integration layers to rehydrate typed payloads
8+
/// from stable contract identifiers instead of inferring payload shape at runtime.
9+
/// </summary>
10+
public static class SideEffectDataContractRegistry
11+
{
12+
private static readonly ConcurrentDictionary<(string ContractType, int ContractVersion), Type> TypesByContract = new();
13+
14+
/// <summary>
15+
/// Registers typed side effect payload contract.
16+
/// </summary>
17+
/// <typeparam name="TData">The payload type to register.</typeparam>
18+
public static void Register<TData>()
19+
=> Register(typeof(TData));
20+
21+
/// <summary>
22+
/// Registers typed side effect payload contract.
23+
/// </summary>
24+
/// <param name="dataType">The payload type to register.</param>
25+
public static void Register(Type dataType)
26+
{
27+
ArgumentNullException.ThrowIfNull(dataType);
28+
29+
var contract = GetRequiredContract(dataType);
30+
TypesByContract[(contract.ContractType, contract.ContractVersion)] = dataType;
31+
}
32+
33+
/// <summary>
34+
/// Attempts to resolve payload CLR type from side effect data contract.
35+
/// </summary>
36+
/// <param name="contractType">The stable contract identifier.</param>
37+
/// <param name="contractVersion">The contract version.</param>
38+
/// <param name="dataType">The resolved CLR type when present.</param>
39+
/// <returns><see langword="true"/> when the contract is registered; otherwise <see langword="false"/>.</returns>
40+
public static bool TryResolve(string contractType, int contractVersion, out Type? dataType)
41+
{
42+
if (string.IsNullOrWhiteSpace(contractType) || contractVersion <= 0)
43+
{
44+
dataType = null;
45+
return false;
46+
}
47+
48+
return TypesByContract.TryGetValue((contractType, contractVersion), out dataType);
49+
}
50+
51+
/// <summary>
52+
/// Reads the declared side effect data contract for CLR type.
53+
/// </summary>
54+
/// <typeparam name="TData">The payload type.</typeparam>
55+
/// <returns>The declared side effect data contract.</returns>
56+
public static SideEffectDataContractAttribute GetRequiredContract<TData>()
57+
=> GetRequiredContract(typeof(TData));
58+
59+
/// <summary>
60+
/// Reads the declared side effect data contract for CLR type.
61+
/// </summary>
62+
/// <param name="dataType">The payload type.</param>
63+
/// <returns>The declared side effect data contract.</returns>
64+
public static SideEffectDataContractAttribute GetRequiredContract(Type dataType)
65+
{
66+
ArgumentNullException.ThrowIfNull(dataType);
67+
68+
return dataType.GetCustomAttributes(typeof(SideEffectDataContractAttribute), inherit: false)
69+
.OfType<SideEffectDataContractAttribute>()
70+
.SingleOrDefault()
71+
?? throw new InvalidOperationException(
72+
$"Typed side effect payload '{dataType.FullName}' must declare {nameof(SideEffectDataContractAttribute)}.");
73+
}
74+
}

0 commit comments

Comments
 (0)