How to 'make illegal state irrepresentable' in this situation?

I have the following situation:

  • There are Movies, which have a title (and more properties)
  • There are Users, which have a name (and more properties)
  • Users can “own” zero to many movies
  • Every user can rate every movie (even the ones they don’t own), and their rating is remembered on a per-user basis

This is the best I came up with:

type alias User = 
  { 
    id: Int
  , name: String
    -- More potential props here
  }

type alias Movie =
  { 
    id: Int
  , title: String
  -- More potential props here
  }

type alias MovieUserData =
  {
    movieId: Int
  , userId: Int
  , isOwned: Bool
  , rating: Int
  }
-- In addition to storing Users and Movies, I store a Set of MovieUserData to capture their relationships

But it feels clunky and not very Elmy, and of course illegal states are still possible. What would be the best way to represent this situation in elm?

Thank you!

In this case as owned and rated movies are indepent things and they related to a user, User type itself could have fields: owned : Set MovieId, rated : Dict MovieId Rating. Also try not use use primitive types (String, Int) for representing domain data, use custom types.

But the actual structure should depend on functionality you need to implement in your app.

2 Likes

So, Here is how you build it.

  • There are Movies , which have a title (and more properties)
type alias MovieId = Int

type alias Movie = {
    id : MovieId,
    title : String,
    plot : String,
}
  • There are Users , which have a name (and more properties)
type alias User = {
    id : Int,
    firstName : String,
    lastName : String,
}

  • Users can “own” zero to many movies

type Owned = 
    Zero
    | Many MovieId (List MovieId)

# Now Change User record to the following

type alias User = {
    id : Int,
    firstName : String,
    lastName : String,
    owned : Owned
}

  • Every user can rate every movie (even the ones they don’t own), and their rating is remembered on a per-user basis
type alias User = {
    id : Int,
    firstName : String,
    lastName : String,
    owned : Owned,
    ratedMovies : List MovieId
}

Hopefully, you’ll get the idea

1 Like

Thank you both for your suggestions:

Since you both used ID based solutions: Is there a way to ensure consistency? For example, how to make sure that a user doesn’t “own” a movieId which doesn’t exist?

If thats the case then the Movies type should have fields called owned : List UserId, ratedBy : List UserId not the User type. This will stay consistent as long as User is not removed or deleted.

I don’t think there’s any good way of modelling away your “impossible state” without introducing a tighter (and probably undesired) coupling between Movie and User. You are dealing with two distinct entities. Consistency between them isn’t guaranteed at a type level.

I’d ask myself whether the state where a non-existing movie is owned actually incurs a problem. What does it mean for a user to own a movie that does not seem to exist? Maybe it means that the movie did exist at some point in the past. It doesn’t necessarily have to be an impossible state. Here’s what I’d probably do:

type alias User =
    { id : Int
    , name : String
    , ratedMovies : Dict Int Int
    , ownedMovies : Set Int
    }


type alias Movie =
    { id : Int
    , rating : Int
    }

-- Movies that are owned, but not found, simply get filtered out

ownedMovies : User -> List Movie -> List Movie
ownedMovies user allMovies =
    ...


ratedMovies : User -> List Movie -> List ( Movie, Int )
ratedMovies user allMovies =
    ...

If it’s really important to handle the case of non-existing movies, the functions could return Results instead:

type IntersectionError
    = MovieDoesNotExist Int

ownedMovies : User -> List Movie -> Result IntersectionError (List Movie)
ownedMovies user allMovies =
    ...


ratedMovies : User -> List Movie -> Result IntersectionError (List ( Movie, Int ))
ratedMovies user allMovies =
    ...

And as mentioned by @romper, it might be a good idea to introduce more domain-specific types. Otherwise, your code conveys that a rating can be any Int in the range of -2^31 - 2^31 - 1

1 Like

Where is this persisted? If in a relational DB like Postgres, you are better off using referential integrity in the DB.

1 Like

In this approach you don’t enforce it in model because maintaining proper relations would take quite some code and unless what you are doing is a MySQL competitor just not worth it. If user rated movie while it was available then keep it and filter out all deleted ones in rendering.

Thank you @alexander-js, I really like this solution! You mentioned:

Just out of curiosity, could you sketch what this would look like? Having a hard time imagining it.

@Sebastian No persistence at all right now, just trying to wrap my head around the data model.

@stiff I see, thank you. I wasn’t sure if using IDs like this was ‘the proper elm way’ to do things, seems like it is though. Thanks!

1 Like

This thread and the linked talk by Richard Feldman provide some good thoughts on this topic Model as a relational database for components

1 Like

Adding to what @alexander-js said: Isn’t the set of movies that exist really the set of movies that someone owns? If I make a vacation movie on my phone, is that a legit movie that some people (my hobby filmmakers’ club) are allowed to rate? It depends on your use case but I think it should be allowed to exist outside a centralized notion of legit movies. The list of movies is just a foldl over owned movies.

The real question is, though, how to avoid two different movies with clashing IDs. UUIDs are a solution to that but may be too heavyweight in a simple use case.

Just my two cents, hope I wasn’t too confusing.

This is what I mean:

type alias User =
    { id : Int
    , name : String
    }

type alias Movie =
    { id : Int
    , rating : Int
    , ownedBy : Set Int
    , ratedBy : Dict Int Int
    }

Strictly speaking, this coupling isn’t really tighter, the responsibilities are just flipped. Rather than User depending on Movie, Movie depends on User. This means that a Movie can’t exist without the notion of a User. This removes the problematic state of “a user owning a movie that does not exist”. However, it introduces the possible state of “a movie being owned by a user that does not exist”. It merely exchanges the problem for another.

Another way of modelling the solution, as per what @hasko wrote:

type alias User =
    { id : Int
    , name : String
    , ratedMovies : Dict Int Movie
    , ownedMovies : List Movie
    }


type alias Movie =
    { id : Int
    , rating : Int
    }

allMovies : List User -> List Movie
allMovies users =
    ...

This approach derives all movies from users. This is (potentially) problematic because it assumes that all movies are owned. It implicitly couples Movie to the notion of ownership.

You could complement this by fetching unowned movies separately and merging them:


unownedMovies : List Movie
unownedMovies =
    ...

ownedMovies : List User -> List Movie
ownedMovies users =
    ...

{-| Combine all owned movies with unowned movies -}
allMovies : List Movie
allMovies =
    ...

This renders the state of “a user owning a movie that does not exist” impossible. It also introduces duplication of data. Suppose the same movie exists in both User.ratedMovies and unownedMovies. Which one should be used in allMovies?

@alexander-js Thank you so much for the detailed and very helpful response! I totally understand what you mean now. I think this is the problem of access path dependence, that structs generally have. But I know what to pick now for my use case (probably going with the first solution you posted).

@hasko Thanks for your comment. In my case, not all movies have to be owned necessarily. Ownership is more like saying “I have this movie in my library”. So it’s very possible that some movies could be unowned entirely (it’s actually the starting state for all movies).

@opsb Thank you very much for linking that thread and video. I watched the entire video and it was exactly what I needed. @rtfeldman is really an amazing teacher. I do have a question though: On the slide at 26:29 in the video, he stores the data as follows:

type alias User = 
    { userId : UserId
    , age : Int
    }

type alias Model = 
    { users : Dict UserId User
    , residents : Dict UserId CityId
    }

Isn’t this again creating duplicated data (the UserId), once as the key to the users Dict in the model, and once in the User record? Is there a reason why he’s doing this? It confused me because just a few slides earlier, he had some very similar data structures, and there he did not do that (basically the userId property was omitted).

1 Like

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.