lightbend / kalix-jvm-sdk

Java and Scala SDKs for Kalix
https://docs.kalix.io/java/index.html
Other
58 stars 39 forks source link

scala SDK - framework agnostic domain logic in Kalix #1672

Open aklikic opened 1 year ago

aklikic commented 1 year ago

Problem:

When writing a framework agnostic domain logic in Kalix it requires huge amount of copy-paste code

Porposed solution:

For command and event classes generated from proto files to have common base class/trait where less amount of copy-paste code would be required.

Example

Domain logic (in case of common base class/trait):


class CompanyAccess(context: EventSourcedEntityContext) extends AbstractCompanyAccess {

  private val log: Logger = LoggerFactory.getLogger(classOf[CompanyAccess])

  // Add extra methods for the Logger instance defined by LoggingUtils
  implicit def improvedLogger(log: Logger) = new LoggingUtils(log)

  protected def now: Instant = Instant.now()

  override def emptyState: CompanyAccessState = CompanyAccess.emptyState

  // KALIX COMMAND HANDLERS

  def processCommand(
      state: CompanyAccessState,
      command: CompanyAccessCommand // <- Base trait for all Company Access entity commands
  ): EventSourcedEntity.Effect[Empty] =
    CompanyAccess.onError(state, command) match {
      case Some((msg, code)) =>
        log.logCommandValidationErrors(command, state, msg, code)
        effects.error(msg, ErrorMapper.map(code))
      case None =>
        val events = CompanyAccess.createEvents(
          state,
          command,
          EventMetadata(eventTime = Option(Timestamp(now)))
        )
        log.logHandledCommand(command, events, state)
        effects.emitEvents(events).thenReply(_ => Empty.defaultInstance)
    }

  override def modifyCompanyAccess(
      state: CompanyAccessState,
      command: ModifyCompanyAccessCommand
  ): EventSourcedEntity.Effect[Empty] = processCommand(state, command)

  override def expireCompanyAccessProposal(
      state: CompanyAccessState,
      command: ExpireCompanyAccessProposalCommand
  ): EventSourcedEntity.Effect[Empty] = processCommand(state, command)

  override def approveReceivedAccess(
      state: CompanyAccessState,
      command: ApproveReceivedAccessCommand
  ): EventSourcedEntity.Effect[Empty] = processCommand(state, command)

  override def rejectReceivedAccess(
      state: CompanyAccessState,
      command: RejectReceivedAccessCommand
  ): EventSourcedEntity.Effect[Empty] = processCommand(state, command)

  override def activateCompanyAccess(
      state: CompanyAccessState,
      command: ActivateCompanyAccessCommand
  ): EventSourcedEntity.Effect[Empty] = processCommand(state, command)

  override def removeCompanyAccessAsSharingCompany(
      state: CompanyAccessState,
      command: RemoveCompanyAccessAsSharingCompanyCommand
  ): EventSourcedEntity.Effect[Empty] = processCommand(state, command)

  override def removeCompanyAccessAsReceivingCompany(
      state: CompanyAccessState,
      command: RemoveCompanyAccessAsReceivingCompanyCommand
  ): EventSourcedEntity.Effect[Empty] = processCommand(state, command)

  override def updateNoteAsSharingCompany(
      state: CompanyAccessState,
      command: UpdateNoteAsSharingCompanyCommand
  ): EventSourcedEntity.Effect[Empty] = processCommand(state, command)

  override def updateNoteAsReceivingCompany(
      state: CompanyAccessState,
      command: UpdateNoteAsReceivingCompanyCommand
  ): EventSourcedEntity.Effect[Empty] = processCommand(state, command)

  // KALIX EVENT HANDLERS

  def handleEvent(
      state: CompanyAccessState,
      event: CompanyAccessEvent // <- Base trait for all Company Access entity events
  ): CompanyAccessState = {
    val newState = CompanyAccess.applyEvent(state, event)
    val metadata = event.metadata
      .map(_.eventTime)
      .map(eventTime => StateMetadata(created = eventTime, lastUpdate = eventTime))
    val newStateWithMetadata = newState.copy(metadata = metadata)
    log.logHandledEvent(context.entityId, event, state, newStateWithMetadata)
    newStateWithMetadata
  }

  override def companyAccessModified(
      state: CompanyAccessState,
      event: CompanyAccessModified
  ): CompanyAccessState = handleEvent(state, event)

  override def companyAccessProposalExpired(
      state: CompanyAccessState,
      event: CompanyAccessProposalExpired
  ): CompanyAccessState = handleEvent(state, event)

  override def sharedAccessProposed(
      state: CompanyAccessState,
      event: SharedAccessProposed
  ): CompanyAccessState = handleEvent(state, event)

  override def receivedAccessApproved(
      state: CompanyAccessState,
      event: ReceivedAccessApproved
  ): CompanyAccessState = handleEvent(state, event)

  override def receivedAccessRejected(
      state: CompanyAccessState,
      event: ReceivedAccessRejected
  ): CompanyAccessState = handleEvent(state, event)

  override def companyAccessActivated(
      state: CompanyAccessState,
      event: CompanyAccessActivated
  ): CompanyAccessState = handleEvent(state, event)

  override def companyAccessRemovedBySharingCompany(
      state: CompanyAccessState,
      event: CompanyAccessRemovedBySharingCompany
  ): CompanyAccessState = handleEvent(state, event)

  override def companyAccessRemovedByReceivingCompany(
      state: CompanyAccessState,
      event: CompanyAccessRemovedByReceivingCompany
  ): CompanyAccessState = handleEvent(state, event)

  override def noteUpdatedBySharingCompany(
      state: CompanyAccessState,
      event: NoteUpdatedBySharingCompany
  ): CompanyAccessState = handleEvent(state, event)

  override def noteUpdatedByReceivingCompany(
      state: CompanyAccessState,
      event: NoteUpdatedByReceivingCompany
  ): CompanyAccessState = handleEvent(state, event)
}
octonato commented 1 year ago

For command and event classes generated from proto files to have common base class/trait where less amount of copy-paste code would be required.

Commands and events are generated by ScalaPB. We generate only the entities. That is to say, our codegen is not responsible for generating all classes, only the component's skeletons.

There are means to ask ScalaPB to make the generated classes extend a given trait, but that requires the user to learn about it and use some ScalaPB extensions. See https://github.com/lightbend/kalix-proxy/issues/1738#issuecomment-1429969165

But that opens the door to other kinds of issues, for example, the proto needs to be cleaned up before being used to generate clients.

aklikic commented 1 year ago

FYI I tried this and it seems to give us what we want out-of-the-box:

import "scalapb/scalapb.proto";
option (scalapb.options) = {
  // Generate the base trait.
  preamble: ["sealed trait CompanyAccessEvent {}"];
  single_file: true;
};
message CompanyAccessModified {
  option (scalapb.message).extends = "CompanyAccessEvent";
  string sharing_company_id = 1;
  string receiving_company_business_id = 2;
  string permission = 3;
  EventMetadata metadata = 1000;
}
import "scalapb/scalapb.proto";
option (scalapb.options) = {
  // Generate the base trait.
  preamble: ["sealed trait CompanyAccessCommand {}"];
  single_file: true;
};
message ModifyCompanyAccessCommand {
  option (scalapb.message).extends = "CompanyAccessCommand";
  string sharing_company_id = 1 [(kalix.field).entity_key = true];
  string receiving_company_business_id = 2 [(kalix.field).entity_key = true];
  string permission = 3;
  string note = 4;
}

Generated commands end events then have with CompanyAccessCommand and with CompanyAccessEvent.

aklikic commented 1 year ago

Scalapb fulfills this requirement.