From 4a9b309864dbe50d3e7ec0fe7ed157293b8383b1 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Wed, 28 Aug 2024 11:22:03 -0400 Subject: [PATCH] fix: remove misalignment of answer and preference terms (#65) --- docs/ubiquitous_language.md | 78 ++++++++++++++++++++++++------- lib/flick/ranked_voting/vote.ex | 4 +- test/flick/ranked_voting_test.exs | 8 ++-- 3 files changed, 67 insertions(+), 23 deletions(-) diff --git a/docs/ubiquitous_language.md b/docs/ubiquitous_language.md index bfb4fb9..66f1a5f 100644 --- a/docs/ubiquitous_language.md +++ b/docs/ubiquitous_language.md @@ -2,36 +2,80 @@ ## Domain Terms -* Ballot Owner -- The user who created and can administer the ballot. -* Voter -- A user who is encouraged to answer the question presented on the ballot. -* Ballot -- A user creates a ballot when they want to present a question to a potential voter and capture ranked answers. -* Question -- A phrase that inspires a voter to provide a collection of ranked answers. -* Possible Answers -- When building a question on a ballot, a ballot owner creates a comma-separated list of possible answers for the ballot question. -* Ranked Answer -- The captured ranked answer provided by a voter for a question. +* **Ballot Owner** -- The user who created and can administer the ballot. +* **Voter** -- A user who is encouraged to answer the question presented on the + ballot with their preferences. +* **Ballot** -- A user creates a ballot when they want to present a question to + a potential voter and capture ranked answers (preferences). +* **Question** -- A phrase that inspires a voter to provide a collection of + ranked answers. +* **Possible Answers** -- When building a question on a ballot, a ballot owner + creates a comma-separated list of possible answers for the ballot question. +* First Preference—When a user comes to have their vote captured, we ask for + their preferences. (See `Sharp Edges #1` below for more info.) +* **Ranked Answer** -- The captured ranked answer provided by a voter for a + question. + +## Sharp Edges + +1. In the code, we have a schema for `Flick.RankedVoting.RankedAnswer`, and when + a user is creating a ballot, we ask them for `Possible Answers (comma + separated)`. However, when capturing a vote, we ask those users for their + `First Preference`, `Second Preference`, etcetera. Under the hood, these + "preferences" are stored as `RankedAnswers` which feels like an unfortunate + misalignment of terms. Currently, the presentation perspective is worth the + cost of this misalignment, but feedback is welcome. ## Software Terms ### Structs, Values, and Entities -* Struct -- When we need to represent a complex domain concept that Elixir primitives can not represent, we often lean on [Elixir Structs] and [Ecto Schemas] to build those concepts. -* Entity -- Many domain concepts are not defined primarily by their attributes but rather by a thread of continued identity; these are called entities. Entities typically change over time, and equality is based on that identity, not its attributes. -* Value Object -- When you only care about the attributes of a domain concept, classify it as a value object. These value objects describe things but have no identity in and of themselves. Generally, value objects do not change over time. +* **Struct** -- When we need to represent a complex domain concept that Elixir + primitives can not represent, we often lean on [Elixir Structs] and [Ecto + Schemas] to build those concepts. +* **Entity** -- Many domain concepts are not defined primarily by their + attributes but rather by their continued identity; these are called entities. + Entities typically change over time, and equality is based on identity, not + attributes. +* **Value Object** -- When you only care about the attributes of a domain + concept, classify it as a value object. These value objects describe things + but have no identity in and of themselves. Generally, value objects do not + change over time. -> **Aside:** Working in the Elixir environment, we tend to say `Value` over `Value Object` since that distinction is needed only for object-oriented languages. +> **Aside:** Working in the Elixir environment, we tend to say `Value` over +> `Value Object` since that distinction is needed only for object-oriented +> languages. [Elixir Structs]: https://hexdocs.pm/elixir/main/structs.html [Ecto Schemas]: https://hexdocs.pm/ecto/Ecto.html#module-schema -#### Is an `Address` a value object? It depends. +#### An example: Is an `Address` an entity or a value object? It depends. -Whether or not any domain concept should consider an entity or a value object depends entirely on its usage. An example from [Domain-Driven Design]: +Whether or not any domain concept should consider an entity or a value object +depends entirely on its usage. An example from [Domain-Driven Design]: -> In software for a mail-order company, an address is needed to confirm the credit card, and to address the parcel. But if a roommate also orders from the same company, it is not important to realize they are in the same location. Address is a VALUE OBJECT. +> In software for a mail-order company, an address is needed to confirm the +> credit card, and to address the parcel. But if a roommate also orders from the +> same company, it is not important to realize they are in the same location. +> Address is a VALUE OBJECT. > -> In software for the postal service, intended to organize delivery routes, the country could be formed into a hierarchy of regions, cities, postal zones, and blocks, terminating in individual addresses. These address objects would derive their zip code from their parent in the hierarchy, and if the postal service decided to reassign postal zones, all the addresses within would go along for the ride. Here, Address is an ENTITY. +> In software for the postal service, intended to organize delivery routes, the +> country could be formed into a hierarchy of regions, cities, postal zones, and +> blocks, terminating in individual addresses. These address objects would +> derive their zip code from their parent in the hierarchy, and if the postal +> service decided to reassign postal zones, all the addresses within would go +> along for the ride. Here, Address is an ENTITY. > -> In software for an electric utility company, an address corresponds to a destination for the company's lines and service. If roommates each called to order electrical service, the company would need to realize it. Address is an ENTITY. Alternatively, the model could associate utility service with a "dwelling," an ENTITY with an attribute of address. Then Address would be a VALUE OBJECT. +> In software for an electric utility company, an address corresponds to a +> destination for the company's lines and service. If roommates each called to +> order electrical service, the company would need to realize it. Address is an +> ENTITY. Alternatively, the model could associate utility service with a +> "dwelling," an ENTITY with an attribute of address. Then Address would be a +> VALUE OBJECT. > -> Tracking the identity of ENTITIES is essential, but attaching identity to other objects can hurt system performance, add analytical work, and muddle the model by making all objects look the same. +> Tracking the identity of ENTITIES is essential, but attaching identity to +> other objects can hurt system performance, add analytical work, and muddle the +> model by making all objects look the same. -[Domain-Driven Design]: https://www.goodreads.com/book/show/179133.Domain_Driven_Design +[Domain-Driven Design]: + https://www.goodreads.com/book/show/179133.Domain_Driven_Design diff --git a/lib/flick/ranked_voting/vote.ex b/lib/flick/ranked_voting/vote.ex index ec75ae2..411cf97 100644 --- a/lib/flick/ranked_voting/vote.ex +++ b/lib/flick/ranked_voting/vote.ex @@ -136,7 +136,7 @@ defmodule Flick.RankedVoting.Vote do value = get_field(changeset, :value) if Map.get(ranked_answer_frequencies, value) > 1 and value not in ["", nil] do - add_error(changeset, :value, gettext("answers must not be duplicated")) + add_error(changeset, :value, gettext("duplicates are not allowed")) else changeset end @@ -168,7 +168,7 @@ defmodule Flick.RankedVoting.Vote do value = get_field(changeset, :value) if value in ["", nil] do - add_error(changeset, :value, gettext("first answer is required")) + add_error(changeset, :value, gettext("can't be blank")) else changeset end diff --git a/test/flick/ranked_voting_test.exs b/test/flick/ranked_voting_test.exs index bd913b8..06e79aa 100644 --- a/test/flick/ranked_voting_test.exs +++ b/test/flick/ranked_voting_test.exs @@ -309,12 +309,12 @@ defmodule Flick.RankedVotingTest do test "success: a vote can contain an optional `full_name` value", ~M{published_ballot} do published_ballot_id = published_ballot.id + assert {:ok, %Vote{ballot_id: ^published_ballot_id, full_name: "John Doe"}} = RankedVoting.create_vote(published_ballot, %{ "ranked_answers" => [%{"value" => "Sushi"}], "full_name" => "John Doe" }) - end test "failure: a vote should not include an answer value that is not present in the ballot", @@ -351,9 +351,9 @@ defmodule Flick.RankedVotingTest do tacos = Enum.at(ranked_answers_changesets, 1) pizza_2 = Enum.at(ranked_answers_changesets, 2) - assert "answers must not be duplicated" in errors_on(pizza_1).value + assert "duplicates are not allowed" in errors_on(pizza_1).value assert %{} == errors_on(tacos) - assert "answers must not be duplicated" in errors_on(pizza_2).value + assert "duplicates are not allowed" in errors_on(pizza_2).value end test "failure: a vote needs to include at least one ranked answer", ~M{published_ballot} do @@ -369,7 +369,7 @@ defmodule Flick.RankedVotingTest do assert {:error, changeset} = RankedVoting.create_vote(published_ballot, attrs) %Ecto.Changeset{changes: %{ranked_answers: ranked_answers_changesets}} = changeset first_ranked_answer = Enum.at(ranked_answers_changesets, 0) - assert "first answer is required" in errors_on(first_ranked_answer).value + assert "can't be blank" in errors_on(first_ranked_answer).value end test "failure: a vote can not be created for an unpublished ballot" do