Whenever you deal with similarities (items can have different kinds of parents), you need to look for the abstractions.
The correct solution depends on a few considerations. One major question here is whether supergroups are actually different from groups, or whether you're just naming them "supergroups" because they contain other groups but they otherwise behave the same.
If groups and supergroups behave the same, then the solution is simple: a group can contain items and subgroups. The same is true of each subgroup.
public class Group { public Item[] Items { get; set; } public Group[] Subgroups { get; set; } }
You mentioned in the comments that there is a limitation on how many group levels you have, this is something you can enforce using business logic rather than letting this steer your class design.
If supergroups are different from group, the next question is whether a supergroup can be derived from a group. If that is possible, then you can solve this using simple inheritance:
public class Group { public Item[] Items { get; set; } } public class SuperGroup : Group { public Group[] Groups { get; set; } }
Because of the inheritance, any SuperGroup will also have its own Items property.
If they cannot derive from one another, then we are left with only one similarity between the two: they can both contain items. Since that is still shared logic, it can be abstracted into its own class/interface.
public interface IItemContainer { Item[] Items { get; set; } } public class Group : IItemContainer { public Item[] Items { get; set; } } public class SuperGroup : IItemContainer { public Item[] Items { get; set; } }
In all of the above cases, the "item containing" behavior of both groups and supergroups can now be reusably developed.
My first thought was create some sort of interface IGroup, like below. This avoids hardcoding the business rule, but it might potentially add an extra table in my database schema (in case it's not possible to have Group and Supergroup in the same table)
First of all, if having that extra table ensures a better data format, then you shouldn't avoid the better data format just to save on one table. Your domain shouldn't be built based on your database format.
Secondly, you can't have a table for an interface. While you could serialize data into the table, you wouldn't be able to deserialize data from that table, since you need a concrete class to deserialize it to. And if you have that concrete class, then you might as well just use that concrete class to begin with.
Other than that, if you happen to be using Entity Framework, there are some options here on whether you use table-per-type, table-per-hierarchy, or table-per-concrete class. I very much prefer the latter in cases where it's applicable, but do note that EF Core does not yet support it as of right now.
Some notes:
- I used arrays here, but any type of collection would work.
- Whether you use interfaces or base classes is up to you. Interfaces are generally preferred but either would work.
- Limitations on group recursion depth are better solved using business logic instead of trying to mold your classes. It's more maintenance-friendly and it keeps your classes easier to (reusably) handle.
- I made them publically settable properties for the sake of example. Feel free to change their access modifiers as you see fit (e.g. readonly).