如何将DDD聚合与时间序列结合?聚合根设计及数据加载疑问
Hey there! Let's tackle your DDD time-series data management problem from two angles: first, whether your aggregate root design makes sense, then a more elegant way to load filtered Sample subsets.
First up, let's unpack making both Site and Signal aggregate roots. In DDD, aggregate roots define the boundary for consistency and transactional integrity. If Site contains multiple Signals, making Signal an independent aggregate root can create unintended issues:
- It implies Signal can be modified or persisted outside the context of its parent Site, which might break core business rules (like ensuring a Signal can't exist without a valid Site, or enforcing Site-level constraints on its Signals).
- You lose the ability to enforce cross-entity consistency through the aggregate root's behavior—something DDD aggregates are designed to handle.
My recommendation here:
- Make Site the sole aggregate root for this hierarchy. Signal and Sample should be internal entities within the Site aggregate. This way, all operations on Signals and Samples must go through the Site root, ensuring your business rules are enforced consistently.
- If you truly need Signal to have its own standalone lifecycle (e.g., reusing Signals across Sites, which doesn't align with your current
SiteIdproperty), then your current design might be justified—but you'll need to handle cross-aggregate consistency manually (like using domain events to sync Site and Signal changes).
Your current repository method works, but it has two key drawbacks: it runs N+1 queries (one for Signals, then one per Signal to load filtered Samples) and it ties you to a custom repository method that feels disconnected from standard DDD patterns. Here are cleaner alternatives:
Option 1: Use EF Core's Filtered Include (EF Core 5+)
If you're on EF Core 5 or later, you can use filtered Include and ThenInclude to load exactly the Samples you need in a single query. This works seamlessly if you adjust your aggregate design to make Site the root:
public interface ISiteRepository : IAsyncRepository<Site> { Task<Site> GetByIdWithFilteredSamplesAsync(int siteId, DateTime from, DateTime to); } public class SiteRepository : EfRepository<Site>, ISiteRepository { public SiteRepository(ForecastingContext dbContext) : base(dbContext) { } public async Task<Site> GetByIdWithFilteredSamplesAsync(int siteId, DateTime from, DateTime to) { return await dbContext.Sites .Where(s => s.Id == siteId) .Include(s => s.Signals) .ThenInclude(sig => sig.Samples.Where(samp => samp.TimeStamp >= from && samp.TimeStamp <= to)) .FirstOrDefaultAsync(); } }
If you stick with Signal as an aggregate root, adapt the pattern directly:
public async Task<IEnumerable<Signal>> GetBySiteIdWithSamplesAsync(int siteId, DateTime from, DateTime to) { return await dbContext.Signals .Where(s => s.SiteId == siteId) .Include(s => s.Samples.Where(samp => samp.TimeStamp >= from && samp.TimeStamp <= to)) .ToListAsync(); }
Note: If you encountered a runtime error with this before, double-check your EF Core version—filtered includes weren't supported prior to EF Core 5.
Option 2: Extend the Specification Pattern
To align this with the Specification pattern, extend your Specification class to include filtered include clauses. This keeps your data access logic consistent with DDD patterns:
public class SignalsBySiteIdWithFilteredSamplesSpecification : Specification<Signal> { public SignalsBySiteIdWithFilteredSamplesSpecification(int siteId, DateTime from, DateTime to) { Query .Where(s => s.SiteId == siteId) .Include(s => s.Samples.Where(samp => samp.TimeStamp >= from && samp.TimeStamp <= to)); } }
Then your repository can use this specification directly, no custom methods needed:
public async Task<IEnumerable<Signal>> ListAsync(Specification<Signal> spec) { return await ApplySpecification(spec).ToListAsync(); }
Option 3: CQRS for Time-Series Queries
Given that time-series data is often read-heavy with complex filtering needs, CQRS is a powerful fit. For read operations, bypass aggregate roots entirely and query the Sample table directly to return tailored DTOs:
public class GetSamplesBySiteAndTimeRangeQueryHandler { private readonly ForecastingContext _dbContext; public GetSamplesBySiteAndTimeRangeQueryHandler(ForecastingContext dbContext) { _dbContext = dbContext; } public async Task<IEnumerable<SampleDto>> Handle(GetSamplesBySiteAndTimeRangeQuery query) { return await _dbContext.Samples .Where(samp => samp.Signal.SiteId == query.SiteId && samp.TimeStamp >= query.From && samp.TimeStamp <= query.To) .Select(samp => new SampleDto { SignalId = samp.SignalId, TimeStamp = samp.TimeStamp, Value = samp.Value }) .ToListAsync(); } }
This approach is highly performant for large datasets because you're only fetching the data you need, without loading entire aggregate roots. You still use aggregate roots for write operations to enforce business rules.
- Adjust your aggregate root design to make Site the single root unless you have a strong business reason to keep Signal independent.
- Use EF Core's filtered includes or extended specifications for cleaner, more performant entity loading.
- For read-heavy time-series scenarios, CQRS is often the most elegant solution to avoid unnecessary aggregate data loading.
内容的提问来源于stack exchange,提问作者Ross Halliday

