domain

package
v1.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jan 24, 2023 License: MIT Imports: 6 Imported by: 0

Documentation

Overview

Package domain implements the business logic for working with boards, users and invites:

  • functions to create, validate and work with instances of boards/users/invites
  • general data store interface BoardDataStore, which makes it easy to switch out the actual storage mechanism for the data
  • BoardService implements operations that can be performed on boards, users and invites

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Board

type Board struct {
	// Random UUIDv4 with prefix "b-"
	BoardId string

	Name        string
	Description string

	// Time the board was created as Unix time (nanoseconds)
	CreatedTime int64
	// The user that created this board.
	// Note that the User value contains the id and name of the user.
	// While the id should be unique and never change, a user usually has the ability to change their name.
	// The name of the user kept with this board would then no longer be up to date.
	// If we don't want to accept these inconsistencies, we would either have to retrieve the name for a given user id separately or
	// update the name here every time it changes.
	CreatedBy User
	// Last time the board was modified (e.g. the name or description changed) as Unix time (nanoseconds)
	ModifiedTime int64
	// User that performed the latest modification
	ModifiedBy User
}

func NewBoard

func NewBoard(name, description string, user User) (Board, error)

func (*Board) IsDescriptionValid

func (b *Board) IsDescriptionValid() error

A board description can be at most descriptionMaxLength bytes long.

func (*Board) IsNameValid

func (b *Board) IsNameValid() error

A board name is valid if it is not empty and not longer than nameMaxLength bytes.

func (*Board) IsValid

func (b *Board) IsValid() error

type BoardCreated

type BoardCreated struct {
	BoardId     string
	Name        string
	Description string
	CreatedTime int64
	CreatedBy   User
}

type BoardDataStore

type BoardDataStore interface {
	// Update a single board and/or some users/invites (belonging to a single board).
	//
	// To implement optimistic concurrency, this method takes a TransactionExpectation (as part of the update parameter).
	// The TransactionExpectation represents the last time the board was modified.
	// Any implementation of this interface should guarantee that the board (and/or its users/invites) is updated only
	// if the board wasn't changed (i.e. it is still in the same state represented by the TransactionExpectation).
	UpdateBoard(ctx context.Context, boardId string, update *DatastoreBoardUpdate) error
	DeleteBoard(ctx context.Context, boardId string) error
	// Returns the board with the given id together with all its users and invites.
	// There are (almost) no separate methods to query or retrieve specific users/invites.
	// This is feasible since the number of users and invites per board is limited.
	// If in the future we wanted to remove these limits, it would make sense to treat boards, board users and board invites
	// as separate entities, with their own data store methods to retrieve and modify them.
	//
	// This method returns a TransactionExpectation that can later be passed back into the UpdateBoard method,
	// to make sure that the board (and its users/invites) wasn't changed before it is updated.
	Board(ctx context.Context, boardId string) (BoardWithUsersAndInvites, TransactionExpectation, error)
	Boards(ctx context.Context, boardIds []string) ([]Board, error)
	BoardsForUser(ctx context.Context, userId string, qp QueryParams) ([]Board, error)

	User(ctx context.Context, boardId string, userId string) (BoardUser, error)

	InvitesForUser(ctx context.Context, userId string, qp QueryParams) (map[string]BoardInvite, error)
}

Any type implementing this interface can be used as the data store for boards, users and invites. We consider a board together with its users and invites as the unit of consistency, they can be retrieved and modified together. To avoid inconsistencies when multiple concurrent requests try to modify a board (and/or its users/invites), we use optimistic transactions/concurrency control (see also the comments on the TransactionExpectation interface and the Board() and UpdateBoard() methods).

type BoardDeleted

type BoardDeleted struct {
	BoardId   string
	DeletedBy User
}

type BoardEdit

type BoardEdit struct {
	UpdateName        bool
	Name              string
	UpdateDescription bool
	Description       string
}

type BoardInvite

type BoardInvite struct {
	// Random UUIDv4 with prefix "i-"
	InviteId string

	// Role a user that accepts the invite would have on the board.
	Role string
	// If not empty, only the given user can accept the invite.
	// If empty, any user can.
	User User

	// Time the invite was created as Unix time (nanoseconds).
	CreatedTime int64
	// User that created the invite.
	CreatedBy User

	// Time the invite expires as Unix time (nanoseconds).
	// After this time the invite can no longer be accepted.
	ExpiresTime int64
}

An invite to join a board. Can be either a general invite, that can be accepted by any user or an invite specific to a user.

func NewBoardInvite

func NewBoardInvite(role string, createdBy User, forUser User, expiryDuration stdtime.Duration) (BoardInvite, error)

func (BoardInvite) IsExpired

func (i BoardInvite) IsExpired() bool

Returns true if the invite has expired.

type BoardService

type BoardService struct {
	// contains filtered or unexported fields
}

BoardService implements operations on boards, users and invites. It uses an implementation of BoardDataStore to read and persist boards/users/invites, and an implementation of EventPublisher to make events available to other components/systems. BoardService does not perform authorization, that should be handled in application services using this package.

We consider a board together with its users and invites as the unit of consistency, i.e. operations on BoardService can run concurrently only for different boards. E.g. if two operations try to concurrently create a new invite for the same user on the same board, at most one of the operations should succeed, since for a board there should be at most one invite for a user at any given time. BoardService guarantees consistency by using the optimistic transaction capabilities required of any BoardDataStore implementation.

BoardService handles transactions explicitly, it tells BoardDataStore what data it expects to have not been changed. Instead it could also be implemented implicitly. For operations that read data, BoardDataStore could store transaction expectations in the context value (see the "session" package for a possible implementation), and read/verify those expecatations when modifying data. This works because in methods of BoardService we always pass the same context value to invocations of BoardDataStore methods. With this approach we could change the BoardDataStore interface to no longer return/accept TransactionExpectation values. A drawback of the implicit approach is that it might not be as obvious or easy to understand for someone reading the BoardService code, what kind of consistency is guaranteed/what data is changed transactionally. Note that both the explicit and implicit approach described here still implement optmistic transactions, the only difference is where the transaction expectations are created/kept. For the given application the number of users of a board is bounded, which in practice makes it very unlikely that concurrent operations that could lead to inconsistencies even happen that often.

Another way of implementing transactions would be to pass an update function to the data store, e.g. func(oldValue Board) (newValue Board) { ... }. I.e. instead of changing/modifying the data in the service method, the service method would pass a function of this type to the datastore. The datastore method would then read the old value, pass it to the function to get the modified value and then persist it. Since all of this happens in the data store implementation, it has complete control to guarantee consistency using a transaction, no need to pass TransactionExpectation back and forth.

func NewBoardService

func NewBoardService(ds BoardDataStore, ep EventPublisher) *BoardService

The BoardDataStore passed must not be nil. The EventPublisher can be, in which case the events will just end up nowhere.

func (*BoardService) AcceptInvite

func (bs *BoardService) AcceptInvite(ctx context.Context, boardId string, inviteId string, user User) error

func (*BoardService) CreateBoard

func (bs *BoardService) CreateBoard(ctx context.Context, name, description string, user User) (BoardWithUsersAndInvites, error)

func (*BoardService) CreateInvite

func (bs *BoardService) CreateInvite(ctx context.Context, boardId string, role string, forUser User, fromUser User) (BoardInvite, error)

func (*BoardService) DeclineInvite

func (bs *BoardService) DeclineInvite(ctx context.Context, boardId string, inviteId string, user User) error

Only invites for a specific user can be declined.

func (*BoardService) DeleteBoard

func (bs *BoardService) DeleteBoard(ctx context.Context, boardId string, user User) error

func (*BoardService) DeleteInvite

func (bs *BoardService) DeleteInvite(ctx context.Context, boardId string, inviteId string) error

func (*BoardService) EditBoard

func (bs *BoardService) EditBoard(ctx context.Context, boardId string, be BoardEdit, user User) (Board, error)

func (*BoardService) EditBoardUser

func (bs *BoardService) EditBoardUser(ctx context.Context, boardId string, userId string, bue BoardUserEdit, user User) (BoardUser, error)

func (*BoardService) RemoveUser

func (bs *BoardService) RemoveUser(ctx context.Context, boardId string, userId string) error

type BoardUser

type BoardUser struct {
	User User
	// Role the user has for the board, see the auth package for available roles.
	// Determines what the user can do on the board.
	Role string
	// Time the user joined the board as Unix time (nanoseconds).
	CreatedTime int64
	// The user that invited this user to the board.
	InvitedBy User
	// Last time this BoardUser was modified (e.g. the role changed) as Unix time (nanoseconds).
	ModifiedTime int64
	ModifiedBy   User
}

func NewBoardUser

func NewBoardUser(user User, role string, invitedBy User) (BoardUser, error)

func (*BoardUser) ChangeRole

func (b *BoardUser) ChangeRole(role string, modifiedBy User) error

type BoardUserEdit

type BoardUserEdit struct {
	UpdateRole bool
	Role       string
}

type BoardWithUsersAndInvites

type BoardWithUsersAndInvites struct {
	Board   Board
	Users   []BoardUser
	Invites []BoardInvite
}

A board with all its users and invites, which we consider the unit of consistency. I.e. the service methods supported by the datastore should guarantee that there are no concurrent operations on a single board (including its users and invites).

This struct does not provide methods to manipulate the sets of users and invites, instead the updates should be encoded using a DatastoreBoardUpdate value.

Note also that since the number of users and invites for a single board is limited, we do not have to worry about the implications of loading all users/invites for performance and memory.

func (BoardWithUsersAndInvites) ContainsInvite

func (b BoardWithUsersAndInvites) ContainsInvite(inviteId string) bool

func (BoardWithUsersAndInvites) ContainsInviteForUser

func (b BoardWithUsersAndInvites) ContainsInviteForUser(userId string) bool

func (BoardWithUsersAndInvites) ContainsUser

func (b BoardWithUsersAndInvites) ContainsUser(userId string) bool

func (BoardWithUsersAndInvites) Invite

func (b BoardWithUsersAndInvites) Invite(inviteId string) (BoardInvite, bool)

func (BoardWithUsersAndInvites) InviteCount

func (b BoardWithUsersAndInvites) InviteCount() int

func (BoardWithUsersAndInvites) InviteForUser

func (b BoardWithUsersAndInvites) InviteForUser(userId string) (BoardInvite, bool)

func (BoardWithUsersAndInvites) User

func (b BoardWithUsersAndInvites) User(userId string) (BoardUser, bool)

func (BoardWithUsersAndInvites) UserCount

func (b BoardWithUsersAndInvites) UserCount() int

type DatastoreBoardUpdate

type DatastoreBoardUpdate struct {
	TransactionExpecation TransactionExpectation

	// Set to true if the board should be updated with the value defined by the "Board" field.
	UpdateBoard bool
	Board       Board

	UpdateUsers []BoardUser
	// User ids of users that should be removed
	RemoveUsers []string

	UpdateInvites []BoardInvite
	// Invite ids of invites that should be removed
	RemoveInvites []string
}

Defines an update to a single board and its users and/or invites.

The set of changes to the users/invites are defined using update and remove lists, instead of just passing the new complete lists of users/invites. This might be useful for data store implementations that keep users and invites separately from their board, e.g. to optimize or make it easier to perform certain queries.

func (*DatastoreBoardUpdate) IsEmpty

func (u *DatastoreBoardUpdate) IsEmpty() bool

func (*DatastoreBoardUpdate) RemoveInvite

func (u *DatastoreBoardUpdate) RemoveInvite(inviteId string) *DatastoreBoardUpdate

func (*DatastoreBoardUpdate) RemoveUser

func (u *DatastoreBoardUpdate) RemoveUser(userId string) *DatastoreBoardUpdate

func (*DatastoreBoardUpdate) UpdateInvite

func (u *DatastoreBoardUpdate) UpdateInvite(invite BoardInvite) *DatastoreBoardUpdate

func (*DatastoreBoardUpdate) UpdateUser

func (*DatastoreBoardUpdate) WithBoard

type EventPublisher

type EventPublisher interface {
	PublishEvent(ctx context.Context, event interface{})
}

Any type implementing this interface can be used by BoardService to publish events. There are many possible types of adaptors for this interface:

  • An event "broker" local to this application, that takes these events and makes them available for other modules to listen to, e.g. a notification module.
  • Publish the events to an external system like Apache Kafka or a cloud service like Google Pub/Sub.

type QueryParams

type QueryParams struct {
	// Valid limit values are between 1 and 100 (inclusive)
	Limit int
	// Cursor for pagination, only return elements that were created at or before the given unix time (nanoseconds).
	// To obtain the cursor value, one can use the created time of the oldest element in the last query and subtract 1.
	// Note that this could skip some elements, if they were created at the exact same time, however this is very unlikely
	// since we use unix time in nanoseconds.
	Cursor int64
}

Parameters for data store queries for boards and invites. Can be used to limit the number of results returned or provide a cursor for pagination. By default, elements will be sorted from newest to oldest (i.e. by descending created time).

func NewQueryParams

func NewQueryParams() QueryParams

func (QueryParams) WithCursor

func (qp QueryParams) WithCursor(cursor int64) QueryParams

func (QueryParams) WithLimit

func (qp QueryParams) WithLimit(limit int) QueryParams

type TransactionExpectation

type TransactionExpectation interface{}

A TransactionExpectation should contain information about when some piece of data was last modified. It is used to implement optimistic transactions/concurrency control.

In particular for BoardDataStore, TransactionExpectation can be used to guarantee consistency in the following way:

  • We can retrieve a board (with all the users and invites) using the Board() method.
  • Board() also returns a value implementing TransactionExpectation, it will typically contain the last time the board (and/or its users/invites) was modified.
  • When we want to update/modify the board (and/or its users/invites), we can pass the TransactionExpectation back to the UpdateBoard() method (as part of the "update" parameter).
  • A correct data store implementation will have to guarantee that the update/modification is only performed if the board (and/or users/invites) was not modified since the time defined in the TransactionExpectation value.

Right now, we will only ever need to pass a single TransactionExpectation. However for a more complicated scenario, the TransactionExpectation interface could require a method "Combine(other TransactionExpectation) TransactionExpectation" that can be used to combine multiple expectations. I.e. the combined expectation would be used to represent the last modification time of multiple pieces of data.

type User

type User struct {
	UserId string
	Name   string
}

User represents the information about a user we need in this context. A User value is used in other types to e.g. represent the user that created a board or an invite.

We assume that users/authentication are managed elsewhere, e.g. another package or an outside system like Firebase Authentication. There a user might have more attributes like an email address, a photo, or birth date.

While the id of a user shouldn't change, the user can possibly change their name in the authentication system that manages users.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL