Architecting a collection of related items of different types
Is using Book as a source of truth not a good approach?
Think of your current setup as a relational database schema where no tables except for Book
have a foreign key reference pointing to anything else. You always have to scan through the Book
table to find any relationships containing books. In the example you gave, you have to walk the entire collection of books to find all the books created by a single author. If you had references going back the other direction, you would only have to find the single author, then look at its Books
property.
How would you currently get the list of authors that haven't written any books? You'd have to scan the list of books to get a list of every author that does have a book, then find each author not in that list.
How can I expose this kind of information to each non-Book tag type without giving each Author or Bookmark access to the global item collection (which feels like a big no-no), or maintaining lists of all relevant tags for each tag type (which just feels really painfully inefficient)?
You're going to need properties that represent every tag type on every item — there's really no way around that. If you want the items in a list to be sorted based on the number of bookmarks that each of them has, then each one needs to offer up the number of bookmarks it has.
But properties don't have to be backed by precomputed lists. They can effectively be instructions about how to do the appropriate joins to get the needed information. For example, the Bookmarks
property of Author
would use the Books
property to get a list of bookmarks:
public IEnumerable<Bookmark> Bookmarks => this.Books.SelectMany(b => b.Bookmarks);
You could also cache the result, if you wanted.
If you choose to continute not to have references from any entity back to Book
and instead made MyItems
available within your model classes, you could do the same sort of thing for relationships pointing to Book
. For example, in Author
:
public IEnumerable<Book> Books => MyItems.OfType<Book>.Where(b => b.Authors.Contains(this));
I don't recommend doing this, though, as you are correct about it not feeling right. It chains your model's implementation to a separate, non-related data structure. My recommendation is to implement direct relationships with lists, and use computed properties for everything else you want to sort by.
I think I'd want the relationships between types to be as ethereal as possible. While most types are easily relatable, some have compound keys or odd relationships, and you just never know...so I'd externalize the finding of related types from the types themselves. Only a lucky few of us have a globally unique consistent key type.
I could imagine letting all your types be both observers and observable. I've never done such a thing out loud...at least, not like this, but it's an interesting possibility...and given 500 points, I figured it would be worth noodling around with ;-)
I'm using the term Tag
to kinda follow your commentary. Maybe Base
makes more sense to you? Anyway, in the following, a Tag
is a type that notifies observing tags and listens to observable tags. I made the observables
be a list of Tag.Subscription
. Normally, you would just have a list of IDisposable
instances, since that's all an observable typically provides. The reason for this is that Tag.Subscription
lets you discover the underlying Tag
...so that you can scrape your subscriptions for your types' list properties in derived types (as shown below in a Author
and Book
.)
I set up the Tag
subscriber/notifier mechanism to work without values per se...just to isolate the mechanism. I assume most Tag
s would have values...but perhaps there are exceptions.
public interface ITag : IObservable<ITag>, IObserver<ITag>, IDisposable
{
Type TagType { get; }
bool SubscribeToTag( ITag tag );
}
public class Tag : ITag
{
protected readonly List<Subscription> observables = new List<Subscription>( );
protected readonly List<IObserver<ITag>> observers = new List<IObserver<ITag>>( );
bool disposedValue = false;
protected Tag( ) { }
IDisposable IObservable<ITag>.Subscribe( IObserver<ITag> observer )
{
if ( !observers.Contains( observer ) )
{
observers.Add( observer );
observer.OnNext( this ); //--> or not...maybe you'd set some InitialSubscription state
//--> to help the observer distinguish initial notification from changes
}
return new Subscription( this, observer, observers );
}
public bool SubscribeToTag( ITag tag )
{
if ( observables.Any( subscription => subscription.Tag == tag ) ) return false; //--> could throw here
observables.Add( ( Subscription ) tag.Subscribe( this ) );
return true;
}
protected void Notify( ) => observers.ForEach( observer => observer.OnNext( this ) );
public virtual void OnNext( ITag value ) { }
public virtual void OnError( Exception error ) { }
public virtual void OnCompleted( ) { }
public Type TagType => GetType( );
protected virtual void Dispose( bool disposing )
{
if ( !disposedValue )
{
if ( disposing )
{
while ( observables.Count > 0 )
{
var sub = observables[ 0 ];
observables.RemoveAt( 0 );
( ( IDisposable ) sub ).Dispose( );
}
}
disposedValue = true;
}
}
public void Dispose( )
{
Dispose( true );
}
protected sealed class Subscription : IDisposable
{
readonly WeakReference<Tag> tag;
readonly List<IObserver<ITag>> observers;
readonly IObserver<ITag> observer;
internal Subscription( Tag tag, IObserver<ITag> observer, List<IObserver<ITag>> observers )
{
this.tag = new WeakReference<Tag>( tag );
this.observers = observers;
this.observer = observer;
}
void IDisposable.Dispose( )
{
if ( observers.Contains( observer ) ) observers.Remove( observer );
}
public Tag Tag
{
get
{
if ( tag.TryGetTarget( out Tag target ) )
{
return target;
}
return null;
}
}
}
}
If absolutely all tags have values, you could merge the following implementation with the foregoing...but I think it just feels better to separate them out.
public interface ITag<T> : ITag
{
T OriginalValue { get; }
T Value { get; set; }
bool IsReadOnly { get; }
}
public class Tag<T> : Tag, ITag<T>
{
T currentValue;
public Tag( T value, bool isReadOnly = true ) : base( )
{
IsReadOnly = isReadOnly;
OriginalValue = value;
currentValue = value;
}
public bool IsReadOnly { get; }
public T OriginalValue { get; }
public T Value
{
get
{
return currentValue;
}
set
{
if ( IsReadOnly ) throw new InvalidOperationException( "You should have checked!" );
if ( Value != null && !Value.Equals( value ) )
{
currentValue = value;
Notify( );
}
}
}
}
While this looks a bit busy, it's mostly vanilla subscription mechanics and disposability. The derived types would be drop-dead simple.
Notice the protected Notify()
method. I started off putting that into the interface, but realized that it's probably not a good idea to make that accessible from the outside world.
So...onto examples; here's a sample Author
. Notice how the AddBook
sets up mutual relations. Not every type would have a method like this...but it illustrates how easy it is to do:
public class Author : Tag<string>
{
public Author( string name ) : base( name ) { }
public void AddBook( Book book )
{
SubscribeToTag( book );
book.SubscribeToTag( this );
}
public IEnumerable<Book> Books
{
get
{
return
observables
.Where( o => o.Tag is Book )
.Select( o => ( Book ) o.Tag );
}
}
public override void OnNext( ITag value )
{
switch ( value.TagType.Name )
{
case nameof( Book ):
Console.WriteLine( $"{( ( Book ) value ).CurrentValue} happened to {CurrentValue}" );
break;
}
}
}
...and Book
would be similar. Another thought regarding the mutual relation; if you accidentally defined the relation both through Book
and Author
, there's no harm, no foul...because the subscription mechanism just quietly skips duplications (I tested the case just to be sure):
public class Book : Tag<string>
{
public Book( string name ) : base( name ) { }
public void AddAuthor( Author author )
{
SubscribeToTag( author );
author.SubscribeToTag( this );
}
public IEnumerable<Author> Authors
{
get
{
return
observables
.Where( o => o.Tag is Author )
.Select( o => ( Author ) o.Tag );
}
}
public override void OnNext( ITag value )
{
switch ( value.TagType.Name )
{
case nameof( Author ):
Console.WriteLine( $"{( ( Author ) value ).CurrentValue} happened to {CurrentValue}" );
break;
}
}
}
...and finally, a little test harness to see if any of it works:
var book = new Book( "Pride and..." );
var author = new Author( "Jane Doe" );
book.AddAuthor( author );
Console.WriteLine( "\nbook's authors..." );
foreach ( var writer in book.Authors )
{
Console.WriteLine( writer.Value );
}
Console.WriteLine( "\nauthor's books..." );
foreach ( var tome in author.Books )
{
Console.WriteLine( tome.Value );
}
author.AddBook( book ); //--> maybe an error
Console.WriteLine( "\nbook's authors..." );
foreach ( var writer in book.Authors )
{
Console.WriteLine( writer.Value );
}
Console.WriteLine( "\nauthor's books..." );
foreach ( var tome in author.Books )
{
Console.WriteLine( tome.Value );
}
...which spit out this:
Jane Doe happened to Pride and...
Pride and... happened to Jane Doe
book's authors...
Jane Doe
author's books...
Pride and...
book's authors...
Jane Doe
author's books...
Pride and...
While I had the list properties being IEnumerable<T>
, you could make them be lazily loaded lists. You'd need to be able to invalidate the list's backing store, but that might flow pretty naturally from your observables.
There are hundreds of ways to go with all this. I tried to not get carried away. Don't know...it would take some testing to figure out how practical this is...but it was sure fun to think about.
EDIT
Something I forgot to illustrate...bookmarks. I guess a bookmark's value is an updateable page number? Something like:
public class Bookmark : Tag<int>
{
public Bookmark( Book book, int pageNumber ) : base( pageNumber, false )
{
SubscribeToTag( book );
book.SubscribeToTag( this );
}
public Book Book
{
get
{
return
observables
.Where( o => o.Tag is Book )
.Select( o => o.Tag as Book )
.FirstOrDefault( ); //--> could be .First( ) if you null-check book in ctor
}
}
}
Then, a Book
might have an IEnumerable<Bookmark>
property:
public class Book : Tag<string>
{
//--> omitted stuff... <--//
public IEnumerable<Bookmark> Bookmarks
{
get
{
return
observables
.Where( o => o.Tag is Bookmark )
.Select( o => ( Bookmark ) o.Tag );
}
}
//--> omitted stuff... <--//
}
The neat thing about that, is authors' bookmarks are their books' bookmarks:
public class Author : Tag<string>
{
//--> omitted stuff... <--//
public IEnumerable<Bookmark> Bookmarks => Books.SelectMany( b => b.Bookmarks );
//--> omitted stuff... <--//
}
For yuks, I made the bookmark take a book on construction...just to illustrate a different approach. Mix and match as needed ;-) Notice that the bookmark doesn't have a list of books...just a single book...because that more correctly fits the model. It's interesting to realize that you could resolve all a books bookmarks from a single bookmark:
var bookmarks = new List<Bookmark>( bookmark.Book.Bookmarks );
...and just as easily get all the authors bookmarks:
var authBookmarks = new List<Bookmark>( bookmark.Book.Authors.SelectMany( a=> a.Bookmarks ) );
In this situation, I would use Id's for the Books, Authors and perhaps even Bookmarks. Any relation between a Book/Author can be captured by the Book having the Author Id, and an Author having a Book Id for example. It also guarantees that Books/Authors will be unique.
Why do you feel the need to let the Book, Author and Bookmark class inherit from the same base class? Is there shared functionality you want to be using?
For the functionality you're seeking, I'd say making some extension methods could be really useful, for example
int GetWrittenBooks(this Author author)
{
//either query your persistent storage or look it up in memory
}
I'd say, make sure you don't put too much functionality in your classes. For example your Book class doesn't have any responsibilities regarding a possible Author birthday, for example. If the birthday of an Author would be in the Author class, the Book shouldn't have access to the birthday of the author, neither should it have Authors, but just references to authors. The book would just be "interested" in which author it has, nothing more/less.
The same goes for an Author: it doesn't have anything to do with the amount of letters on page 150 of Book x, for example. That's the responsibility of the book and it shouldn't concern the author.
tl;dr: Single responsibility principle/Separation of concerns.