Aspecting Abstraction
Namespace: RisingV.Shared.Aspects
Assembly: RisingV.Shared
The Aspect pattern (also called Aspecting) provides a way to attach typed views or behaviors onto an existing domain object (like an ECS Entity
, a data DTO, or any class) without modifying that object’s source. An aspect wraps a target instance and exposes a tailored API for cross‑cutting concerns (validation, convenience methods, helper logic, etc.) while preserving the original model’s purity.
1. Core Interfaces
IAspect
A marker interface with no members:
public interface IAspect { }
This non‑generic base allows you to treat all aspects uniformly when you don’t care about the concrete target type.
IAspect<T>
public interface IAspect<T> : IAspect
{
/// <summary>
/// The wrapped object this aspect operates on.
/// </summary>
T Target { get; }
/// <summary>
/// Returns true if the provided instance satisfies the structural and semantic
/// requirements of this aspect (e.g., required components are present, tags set,
/// or valid state).
/// </summary>
bool Qualifies(T entity);
}
Target
The underlying object (e.g., anEntity
or some DTO) that this aspect augments.Qualifies(T entity)
A guard method that checks whether the passed‐inentity
has the correct shape, components, tags, or state needed to create this aspect. For example, aUnitAspect
might require thatentity.Has<ComponentHealth>() && entity.Has<TagUnit>()
.
2. Base Support: BaseAspect<T>
Most aspects will inherit from BaseAspect<T>
, which implements common logic:
public abstract class BaseAspect<T> : IAspect<T>
{
protected abstract Logger Log { get; }
/// <summary>
/// The wrapped target instance.
/// </summary>
public T Target { get; }
protected BaseAspect(T target)
{
// Verify that target is not null
if (target == null)
throw new UnqualifiedTargetException(target);
if (!Qualifies(target))
throw new UnqualifiedTargetException(target);
this.Target = target;
}
/// <summary>
/// Derived types override this to define qualification logic.
/// </summary>
public abstract bool Qualifies(T entity);
/// <summary>
/// Friendly display of the target for logging (can be overridden).
/// </summary>
protected virtual string DisplayName(T obj)
=> obj?.ToString() ?? string.Empty;
/// <summary>
/// Additional details about the target for error messages (optional).
/// </summary>
protected virtual string ExtraDetails(T obj)
=> string.Empty;
// … additional utility methods to throw with context, etc. …
}
Key points:
- The constructor in
BaseAspect<T>
callsQualifies(target)
and throwsUnqualifiedTargetException
if it returnsfalse
. - Derived aspects implement
Qualifies
to check for required components, tags, or properties. - Use
DisplayName
andExtraDetails
to improve error/log messages. - A
Logger
field is provided for consistent logging inside aspect methods.
3. Handling Unqualified Targets: UnqualifiedTargetException
public class UnqualifiedTargetException : BaseException
{
/// <summary>
/// The actual object that failed qualification.
/// </summary>
public object? Target { get; }
public UnqualifiedTargetException(object? target)
: base(target == null
? "Target is null"
: $"Target {target} is not qualified")
{
Target = target;
}
public UnqualifiedTargetException(string message, object? target)
: base(message)
{
Target = target;
}
public UnqualifiedTargetException(string message, Exception innerException, object? target)
: base(message, innerException)
{
Target = target;
}
}
- Thrown when you attempt to create an aspect around a target that fails
Qualifies
. - Contains the offending
Target
object for diagnostics.
4. Metadata Support: MetaSerializer
public static class MetaSerializer
{
// Provides JSON serialization helpers for aspect‐related data.
// (e.g., generating “aspect metadata” files for documentation or runtime reflection).
public static string Serialize<TAspect>(TAspect aspect)
where TAspect : IAspect
{
// …implementation using reflection attributes and JSON …
}
public static TAspect Deserialize<TAspect>(string json)
where TAspect : IAspect
{
// …reconstruct aspect metadata from JSON, then wrap a target…
}
}
Although not strictly required for simple aspects, MetaSerializer
can be used to:
- Export aspect specifications (e.g., field names, default values) to JSON.
- Rehydrate aspects at runtime or for code generation.
- Combine with source‐generation to produce high‐performance lookup tables.
5. Typical Usage
Below is a step‐by‐step example of creating and using a custom aspect:
Step 1: Define a Domain Type
Assume you have a DOTS Entity
that represents an in‑game “Unit”:
public struct ComponentHealth : IComponentData
{
public int Current;
}
public struct TagUnit : IComponentData { }
Step 2: Create a UnitAspect
public sealed class UnitAspect : BaseAspect<Entity>
{
private readonly EntityManager _mgr;
protected override Logger Log { get; } = Logger.Create<UnitAspect>();
public UnitAspect(Entity entity, EntityManager manager) : base(entity)
{
_mgr = manager;
}
public override bool Qualifies(Entity entity)
{
// Must have both a Health component and be tagged as a Unit
return _mgr.HasComponent<ComponentHealth>(entity)
&& _mgr.HasComponent<TagUnit>(entity);
}
/// <summary>
/// Reduce health by the specified damage amount.
/// </summary>
public void TakeDamage(int damage)
{
var health = _mgr.GetComponentData<ComponentHealth>(Target);
health.Current = Math.Max(0, health.Current - damage);
_mgr.SetComponentData(Target, health);
Log.Info($"Unit {Target} took {damage} damage, now at {health.Current} HP.");
}
/// <summary>
/// Heal the unit by the specified amount.
/// </summary>
public void Heal(int amount)
{
var health = _mgr.GetComponentData<ComponentHealth>(Target);
health.Current += amount;
_mgr.SetComponentData(Target, health);
Log.Info($"Unit {Target} healed by {amount}, now at {health.Current} HP.");
}
}
Step 3: Attach & Use the Aspect
Somewhere in your gameplay code, given an Entity e
:
// Attempt to wrap e as a UnitAspect
UnitAspect? unit;
try
{
unit = new UnitAspect(e, entityManager);
}
catch (UnqualifiedTargetException)
{
// e is not a valid “Unit”
unit = null;
}
if (unit != null)
{
// Safely call aspect methods
unit.TakeDamage(10);
if (unit.TargetExists()) // example helper
unit.Heal(5);
}
Alternatively, you can write an extension method for concise syntax:
public static class EntityExtensions
{
public static UnitAspect AsUnit(this Entity e, EntityManager mgr, bool strict = true)
{
if (!new UnitAspect(e, mgr).Qualifies(e))
{
if (strict)
throw new UnqualifiedTargetException(e);
else
return null!;
}
return new UnitAspect(e, mgr);
}
}
// Usage:
var unit = e.AsUnit(entityManager, strict: false);
unit?.TakeDamage(10);
6. Design Principles & Best Practices
Separation of Concerns
Keep domain data (components, DTOs) free from UI/validation/logging logic. Aspects live outside the core model.Stateless Aspects
Aspects should not store mutable state beyond references to the target and required services (EntityManager
, other managers, etc.).Qualifies Method
- Efficient checks: avoid expensive reflection in
Qualifies
. - Return
false
quickly if missing a required component or tag.
- Efficient checks: avoid expensive reflection in
Throw Early
The base constructor inBaseAspect<T>
immediately throwsUnqualifiedTargetException
if the target fails qualification. This prevents misuse later.Logging & Diagnostics
- Use the built‑in
Logger
to record aspect‐specific events. - Override
DisplayName
/ExtraDetails
in your aspect to provide helpful context in errors.
- Use the built‑in
Composable
You can wrap multiple aspects around the same target if needed. For example, a singleEntity
could have both aUnitAspect
and aMovementAspect
.
7. Troubleshooting
Symptom:
UnqualifiedTargetException: Target Entity(0x1234) is not qualified
Cause:
Your aspect’s Qualifies
check returned false
(missing component/tag).
Fix:
- Ensure the
Entity
has all required components before creating the aspect. - If dynamic, add debug logs in
Qualifies
to see which condition failed.
TL;DR
IAspect<T>
defines a typed wrapper around a target object, with aQualifies
guard.BaseAspect<T>
provides a constructor that enforces qualification and supplies logging helpers.UnqualifiedTargetException
is thrown if the target doesn’t meet the aspect’s requirements.- Use aspects to attach cross‑cutting behavior (validation, helper methods, etc.) without modifying the original data model.
- Implement each aspect by inheriting
BaseAspect<T>
and overridingQualifies(T)
plus any additional methods you need.