ryan-mars / stochastic

TypeScript framework for building event-driven services. Easily go from Event Storming → Code.
MIT License
6 stars 1 forks source link

feat: implement query as request/response contract that aggregates many read models. #48

Closed sam-goodwin closed 3 years ago

sam-goodwin commented 3 years ago

Before, a ReadModel only had a projection function that would run in a Lambda Function and aggregate received events, storing results in a database. Now, the ReadModel also defines a client function that provides an interface for interacting with that aggregated data without having to know the internal implementation details of the ReadModel database. The original projection function of a ReadModel runs in its own contained Lambda Function, whereas the new client query will be run in the container of the consumer (e.g. a Query).

const dynamoTable = new Dependency("SeatsTable")

export const AvailableSeats = new ReadModel(
  {
    __filename,
    events: [ReservationBooked],
    dependencies: [dynamoTable]
  },
  /**
   * Projection function that aggregates events to prepare the read model.
   */
  ({ SeatsTable }) => {
    const ddb = new dynamodb.DynamoDBClient({})

    return async event => {
      await ddb.send(
        new dynamodb.UpdateItemCommand({
          TableName: SeatsTable,
          Key: {
            reservationNo: {
              S: event.reservationNo
            }
          },
          UpdateExpression: "ADD availableSeats :q",
          ExpressionAttributeValues: {
            ":q": {
              N: "1"
            }
          }
        })
      )
    }
  },
  /**
   * Initializer for the interface to this read model.
   */
  ({ SeatsTable }) => {
    const ddb = new dynamodb.DynamoDBClient({})

    return async (reservationNo: string) => {
      const item = await ddb.send(
        new dynamodb.GetItemCommand({
          TableName: SeatsTable,
          Key: {
            reservationNo: {
              S: reservationNo
            }
          }
        })
      )

      if (item.Item?.availableSeats.N !== undefined) {
        return parseInt(item.Item.availableSeats.N, 10)
      } else {
        return null
      }
    }
  }
)

This client function is then passed on to any Query constructs that take a dependency on the ReadModel. A Query is a new first-class construct that defines a Request/Response contract with Shapes. A Query will be hosted in its own contained Lambda environment and can then be called directly or attached to an API Gateway or GraphQL interface (hence the need for a strict contract).

A Query may depend on zero or many read models to perform its work. By taking a dependency on a ReadModel, the Query also inherits all the dependencies from that ReadModel because it will directly interact with the ReadModel's internal state (e.g. a database).

This interaction will use the client function defined in the ReadModel construct but it will be un in the Query's runtime, therefore the Query needs to be bound to those dependencies in the same way as the ReadModel, requiring configuration such as environment variables for ARNs and IAM policies and environment variables.


export class GetSeatAvailabilityRequest extends Shape("GetSeatAvailabilityRequest", {
  reservationNo: string()
}) {}

export class GetSeatAvailabilityResponse extends Shape("GetSeatAvailabilityResponse", {
  reservationNo: string(),
  availableSeats: number()
}) {}

export const AvailabilityQuery = new Query(
  {
    __filename,
    /**
     * We take a dependency on one or mode read models
     */
    models: [AvailableSeats],
    /**
     * And define a request/response contract for the Query.
     */
    request: GetSeatAvailabilityRequest,
    results: GetSeatAvailabilityResponse
  },
  async (request, availableSeats) => {
    /**
     * Then, implement a function to implement the Query's request/response contract
     */
    return new GetSeatAvailabilityResponse({
      reservationNo: request.reservationNo,
      // here, we directly query the read model to get the availability
      availableSeats: (await availableSeats(request.reservationNo)) ?? 0
    })
  }
)
ryan-mars commented 3 years ago

Refactored Aggregate to be called Store . Now we have an interesting situation in stochastic-aws-serverless where we have a StoreConstruct in store-construct.ts which you'd expect to have a table for events but it does not. That exists in the BoundedContextConstruct in bounded-context-construct.ts. Originally I thought you'd only want one event store table per BoundedContext. DynamoDB scales such that a table per store is not necessary under most conditions. Currently the StoreConstruct in store-construct.ts does nothing. If I took the event store table from BoundedContextConstruct and moved it to StoreConstruct you could have one table per store. What would you suppose are the tradeoffs to this?

Keep in mind I still believe there should be one SNS topic per BoundedContext for fan out of events within the BoundedContext, so each Store 's DynamoDB stream will continue to be forwarded by forwarder.ts to the BoundedContext's SNS lone event topic.