kalix-jvm-sdk icon indicating copy to clipboard operation
kalix-jvm-sdk copied to clipboard

scala SDK - framework agnostic domain logic in Kalix

Open aklikic opened this issue 1 year ago • 3 comments

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)
}

aklikic avatar Jun 05 '23 11:06 aklikic

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.

octonato avatar Jun 05 '23 11:06 octonato

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 avatar Jun 06 '23 10:06 aklikic

Scalapb fulfills this requirement.

aklikic avatar Jun 06 '23 12:06 aklikic