Supercharging Go with CEL: Making Your Code More Dynamic

Supercharging Go with CEL: Making Your Code More Dynamic

Lately, I’ve been working on some new projects, and one thing they all have in common is the need for a ton of flexibility — think dynamic filters, custom conditions, user-defined queries, and more. Basically, I needed a way to let users define their own logic without hardcoding every possible scenario into my app. At the same time, I wanted to make sure things stay safe and under control.

That’s when I stumbled across CEL (Common Expression Language) and its Go implementation, cel-go — and it honestly felt like the perfect fit.

Imagine being able to define business rules or validation logic without hardcoding them into your application. Sounds neat, right? Let's dive into how CEL can help us achieve that.

What is CEL?

CEL is a small, fast, and safe expression language designed for embedding in applications. It allows you to write expressions like:

user.age > 18 && user.country == "US"

These expressions can then be evaluated at runtime, making your application more dynamic and adaptable to changing requirements.

Why Use cel-go?

As a Go developer, I appreciate tools that integrate seamlessly with the language. cel-go is the official Go implementation of CEL, enabling us to:

  • Define variables and types
  • Parse and compile expressions
  • Evaluate expressions against data

This means we can externalize our business logic, making it easier to update without touching the core application code.

Real-World Example: Validating User Input

Let's say we have a User struct:

type User struct {
	Name   string
	Age    int
	Email  string
	Active bool
}

We want to ensure that the user is over 18 and has a valid email address. With cel-go, we can define this rule as an expression:

user.Age > 18 && user.Email.contains("@")
package main

import (
	"fmt"
	"log"

	"github.com/google/cel-go/cel"
	"github.com/google/cel-go/checker/decls"
	exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
)

type User struct {
	Name   string
	Age    int
	Email  string
	Active bool
}

func main() {
	env, err := cel.NewEnv(
		cel.Declarations(
			decls.NewVar("user", decls.NewObjectType("main.User")),
		),
	)
	if err != nil {
		log.Fatalf("environment creation error: %v", err)
	}

	ast, iss := env.Compile(`user.Age > 18 && user.Email.contains("@")`)
	if iss.Err() != nil {
		log.Fatalf("compile error: %v", iss.Err())
	}

	prg, err := env.Program(ast)
	if err != nil {
		log.Fatalf("program creation error: %v", err)
	}

	user := User{
		Name:   "Alice",
		Age:    25,
		Email:  "[email protected]",
		Active: true,
	}

	out, _, err := prg.Eval(map[string]interface{}{
		"user": user,
	})
	if err != nil {
		log.Fatalf("evaluation error: %v", err)
	}

	fmt.Println("Is valid user?", out)
}

When you run this code, it should output:

Is valid user? true

This approach allows us to change the validation logic without modifying the Go code—just update the expression.

Filtering Data Dynamically

Another use case is filtering data based on dynamic criteria. Suppose we have a list of products:

type Product struct {
	Name  string
	Price float64
}

We want to filter products where the price is less than 100 and the name contains "Book". Here's how we can do it:

products := []Product{
	{"Book A", 89.99},
	{"Laptop", 999.99},
	{"Book B", 45.0},
}

expr := `product.Price < 100 && product.Name.contains("Book")`

env, _ := cel.NewEnv(
	cel.Declarations(
		decls.NewVar("product", decls.NewObjectType("main.Product")),
	),
)

ast, iss := env.Compile(expr)
if iss.Err() != nil {
	log.Fatalf("compile error: %v", iss.Err())
}
prg, _ := env.Program(ast)

for _, p := range products {
	out, _, _ := prg.Eval(map[string]interface{}{
		"product": p,
	})
	if out.Value().(bool) {
		fmt.Println("Matched:", p.Name)
	}
}

This will output:

Matched: Book A
Matched: Book B

By externalizing the filter criteria, we can easily adjust our filtering logic without changing the code.

Custom Functions in CEL

cel-go also allows us to define custom functions. For instance, let's say we want to check if a user's email domain is "example.com":

func isExampleDomain(email string) bool {
	return strings.HasSuffix(email, "@example.com")
}
env, _ := cel.NewEnv(
	cel.Declarations(
		decls.NewFunction("isExampleDomain",
			decls.NewOverload("isExampleDomain_string",
				[]*exprpb.Type{decls.String},
				decls.Bool,
			),
		),
	),
)

ast, iss := env.Compile(`isExampleDomain(user.Email) && user.Age > 18`)
if iss.Err() != nil {
	log.Fatal(iss.Err())
}

funcLib := cel.Functions(
	&functions.Overload{
		Operator: "isExampleDomain_string",
		Function: func(args ...ref.Val) ref.Val {
			email := args[0].Value().(string)
			if strings.HasSuffix(email, "@example.com") {
				return types.True
			}
			return types.False
		},
	},
)

prg, _ := env.Program(ast, funcLib)

Now, our CEL expressions can utilize custom logic defined in Go.

Conclusion

Using CEL in Go apps can seriously level up your flexibility game — especially when you want users to define their own rules, filters, or queries without breaking your entire system. With cel-go, you can move business logic out of your Go code and keep things clean, dynamic, and way easier to maintain.

One thing I really appreciate about CEL is that it's not Turing-complete — and that’s actually a good thing. It means no infinite loops or wild side effects. CEL is designed to have predictable performance with linear time complexity, which makes it super safe to run even when users are writing the expressions.

So yeah, if you're looking to give your users power without giving away control — CEL might be exactly what you're looking for.